GitHub avatar

Fox's Blog

5 Sneaky Ways to Use GitHub Actions (and What They Teach About Secrets)

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

5 Sneaky Ways to Use GitHub Actions

GitHub Actions, on paper, is for classic CI/CD: you push, it lints, it tests, it deploys. I already wrote about one special 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 to fill a standalone article, less centered on one project, more of a technique catalog.

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

1. A git tag as persistent state between two runs

Quick reminder, the full detail is in the email-autoreply article. GitHub Actions is stateless by design: every run starts from a pristine machine. The workaround: storing a value (an ID, a timestamp, any small 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 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 instead of a branch to avoid polluting the repo's branch list.

2. A git tag as 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, in turn, directly checks out that tag instead of running bun install && bun run build on 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 already ready
- run: node dist/index.js --action

That changes the run from ~20s to ~10s. On a cron that runs often, it counts. actions/cache does similar work (caching dependencies), but a git tag is more direct when you want to outright freeze a versioned artifact and point at it explicitly, rather than just speeding up an npm install.

3. One mandatory check that aggregates several jobs

A small pattern that looks like nothing but changes your life in protected-branch configuration. On konosuba-rpg, 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 that facade job, configuring a protected branch would require ticking three separate mandatory checks, and updating that list every time a job is added or renamed. With test-battery, a single name to tick in the repo settings, which stays stable even as the internal detail changes.

4. Turning a free runner into a temporary VPS

This one is the most twisted of all, and clearly my favorite: repo-to-vps fully repurposes an intended GitHub Actions runner into a Linux machine reachable over SSH, free, for up to 6 hours (the max limit of a job).

The principle: a job that does almost nothing but launch tmate (a tmux fork that exposes a terminal session shareable over 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 throwaway: as soon as the job ends, everything vanishes. An SSH session that lasts hours is useless if everything you do in it evaporates on the next run. The solution: a git branch that acts as a live snapshot of the filesystem, synced continuously.

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

  1. Restores the filesystem from a dedicated filesystem branch at job start (git reset --hard onto it).
  2. Watches file changes with inotifywait continuously, and commits + pushes immediately as soon as 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 instead of creating a new one (git commit --amend --no-edit), so the filesystem branch always stays at a single commit, which avoids accumulating thousands of snapshots.
  2. A while true loop relaunches 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 into a host.conf file, committed on the filesystem branch, so it's retrievable via the GitHub API (gh api .../contents/host.conf) without ever having had access to the job's live logs.
  4. A periodic_save routine runs every 5 seconds in the background, in case inotifywait misses an event.

Result: a full Linux shell, reachable from anywhere, with a filesystem that persists between sessions, even though the underlying infrastructure (a GitHub Actions runner) was never designed for that. The only real limit is the 6h timeout per job: after that 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; // already an open PR

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 most discreet of the five, but probably the most important for the future: the publish.yml workflow of typescript-virtual-container contains no npm secret. 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 + workflow, configured on npmjs.org), without any static token transiting or stored anywhere. Zero secrets to leak, zero tokens to rotate every six months.


GitHub Secrets, in depth

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

A secret is not necessarily a simple string. In email-autoreply, ACCOUNTS_JSON holds the entire minified JSON of the multi-account config: a full data structure rather than a simple API key, 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 an encrypted one, and it updates in one click in the repo settings without touching the code.

GITHUB_TOKEN has precise limits, and that's by design. The automatic token GitHub injects into every run is powerful, but sealed on certain points: by default, it cannot trigger another workflow, and depending on the repo configuration 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 scope job by job, not globally. Every workflow I 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 compromised.

The best secret is the one that doesn't exist. The typescript-virtual-container OIDC pattern is the most polished version of that idea: instead of managing rotation, expiration, and leak 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. An orphan, force-pushed git tag 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 syncing its filesystem continuously to a git branch, with autosave via inotifywait and a single amended commit.
  3. The default GITHUB_TOKEN is deliberately limited: creating cross-branch PRs or publishing without secrets requires either a dedicated PAT or a move 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 usages, 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, plutôt que simplement 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, ce qui évite l'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. Aucun NPM_TOKEN, aucun 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 : une structure de données complète plutôt qu'une simple clé API, 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.

GitHub Actions 的 5 种骚操作(以及它们教会我的 secret 课)

一个被改成免费 VPS 的 CI runner,一个自己给自己开 PR 的机器人,一次零 secret 的 npm 发布。翻我自己的仓库,收集那些走出“lint + test + deploy”的 GitHub Actions 套路。

GitHub Actions 的 5 种骚操作

GitHub Actions 纸面上是干经典 CI/CD 的:你 push,它 lint,它测试,它部署。我之前写过一个特例(拿 git tag 当 bot 邮件数据库,见那篇专文)。但翻我自己的仓库,够得上单独一篇的套路不少,不再围着单个项目转,做个技术合集。

五个用法,从常规到离谱。

1. git tag 当两次运行之间的持久状态

快速 recap,完整版在 email-autoreply 那篇。GitHub Actions 设计上 stateless:每次运行从干净机器启动。变通:把一个值(ID、时间戳,随便什么小状态)存进专用 git tag,不进任何分支。

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

# 写状态(孤儿分支,单提交,强制推 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 干类似活(缓存依赖),但 git tag 更直接,你要的是钉死一个版本化产物、显式指向它,而不是单纯加速 npm install。

3. 一个聚合多个 job 的必选检查

看着不起眼、配保护分支时救命的套路。konosuba-rpg 上 CI 有三个并行独立 job(typecheck、lint、tests),加第四个 test-battery job,唯一工作就是依赖前三个:

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 的预期用途整个掀了,变成最长 6 小时(单个 job 上限)的免费 SSH Linux 机器。

原理:job 几乎只干一件事,启动 tmate(tmux 的 fork,SSH 或浏览器分享终端 session):

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 session,你干的东西下次全蒸发,那要它何用。解法:git 分支当文件系统的实时快照,持续同步。

start-tmate.sh 按顺序做:

  1. job 启动从专用 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. 每次保存 amend 上个提交而不是建新的(git commit --amend --no-edit),filesystem 分支永远一个提交,避免攒几千个快照。
  2. while true 循环 tmate session 死了自动重启,remain-on-exit on 让终端 exit 后还能连。
  3. tmate 生成的 SSH URL 写进 host.conf 文件,提交到 filesystem 分支,经 GitHub API 取(gh api .../contents/host.conf),不用看过 job 实时日志。
  4. periodic_save routine 后台每 5 秒跑一次,防 inotifywait 漏事件。

结果:完整 Linux shell,哪都能连,文件系统跨 session 持久,而底下(GitHub Actions runner)压根不是为这个设计的。唯一真限制是每 job 6 小时超时:到点重跑工作流。

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; // 已经有开的 PR

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

关键是用的 token。这个工作流不用自动 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 工作流没有任何 npm secret。没有 NPM_TOKEN,没有 NODE_AUTH_TOKEN。只有这个:

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 照样能跑,因为 npm 仓库现在支持 trusted publishing 走 OIDC:工作流直接向仓库证明自己身份(精确到仓库加工作流,配在 npmjs.org 那边),没有任何静态 token 传过、存过。零 secret 可漏,零 token 每半年转一次。


GitHub secret 深水区

这五个套路都以某种方式碰 secret 问题。几条我每个工作流反复用的原则:

secret 不一定是简单字符串。 email-autoreply 里 ACCOUNTS_JSON 是整个多账号配置 minify 后的 JSON:完整数据结构,不只是 API key,运行时原样注入文件:

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

不用提交配置文件(哪怕加密),改配置在仓库设置点一下,不碰代码。

GITHUB_TOKEN 有明确边界,故意的。 每个 run 自动注入的 token 很强,但几处被锁死:默认触发不了别的 workflow,按仓库配置可能被分支保护规则拦。这正是 create-pull-request.yml 要求单独 PAT(AUTO_PR_TOKEN)的原因:来自真账号(或 GitHub App)、显式 contents:write + pull-requests:write 权限的 token,和临时的 job token 不是一回事。

权限按 job scoped,别全局。 这里列的每个工作流都有最小 permissions: 块,还带注释:

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

公有仓库默认 GITHUB_TOKEN 历史上权限不小,显式收到 job 真需要的,链条里某个第三方 action(uses: 某人/action@版本)被黑了, blast radius 也小。

最好的 secret 是不存在的 secret。 typescript-virtual-container 的 OIDC 套路是这个思路最彻底的版本:不管理 NPM_TOKEN 的轮换、过期、泄露风险,工作流直接向第三方密码学证明身份(就是这个仓库、就是这个工作流)。AWS、Docker Hub、PyPI 同理:越来越多仓库和云支持 GitHub Actions 的 OIDC。


三句话总结

  1. 孤儿、强制推的 git tag 能当极简数据库,也能当预编译构建缓存:同一机制两种用途。
  2. 免费 GitHub Actions runner 能变持久 SSH shell,只要文件系统持续同步到 git 分支,inotifywait 自动保存,永远 amend 成单个提交。
  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のためのものだ。pushしたらlintして、テストして、デプロイする。私はすでに特殊なケースについて書いたことがある(メールボット用にgitタグをデータベースとして使う方法は専用の記事を参照)。でも自分のリポジトリを掘り返してみると、1つのプロジェクトに絞らないカタログ記事にするだけの価値があるくらい、いろんなパターンが出てきた。

5つの使い方を、王道なものから一番ひねくれたものまで紹介する。

1. gitタグをrun間の永続的な状態として使う

手短なおさらいだが、詳しくはemail-autoreplyの記事にある。GitHub Actionsは設計上ステートレスだ。各runはまっさらなマシンから始まる。回避策は、値(ID、タイムスタンプ、その他小さな状態)をブランチではなく専用のgitタグに保存することだ。

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

# 状態を書く(orphanブランチ、コミットは1つだけ、タグをforce-push)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

ポイントは、履歴を溜めないためのorphanブランチと、リポジトリのブランチ一覧を汚さないためのブランチではなく強制上書きするタグを使うことだ。

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

同じ系統のアイデアで、使い方が違う。アプリの状態ではなくビルド成果物を保存する。buildジョブがコードを1回だけコンパイルし(masterへのpush時)、dist/とnode_modules/をruntimeタグにpushする。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

これでrunが約20秒から約10秒に短縮される。頻繁に回るcronでは効いてくる。actions/cacheも似た仕事をする(依存関係をキャッシュする)が、npm installを速くするだけではなく、バージョン付き成果物をがっちり固定して明示的に指したい場合はgitタグの方が直接的だ。

3. 複数ジョブを集約する必須チェックを1つだけ作る

地味に見えるが、保護ブランチの設定では人生が変わるパターンだ。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があれば、リポジトリ設定でチェックする名前は1つだけで、中身が変わっても安定したまま使える。

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

これが一番ひねくれていて、明らかに私のお気に入りだ。repo-to-vpsはGitHub Actionsランナーの想定用途を完全に踏み外して、無料で最大6時間(1ジョブの上限)使えるSSH接続可能なLinuxマシンに仕立てている。

仕組みは、tmate(SSHやブラウザで共有できるターミナルセッションを公開するtmuxのフォーク)を起動するだけのジョブだ。

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セッションも、そこでやったことが次のrunで蒸発するなら意味がない。解決策は、ファイルシステムのライブスナップショット用gitブランチを用意して、継続的に同期することだ。

start-tmate.shスクリプトは順番にこう動く。

  1. ジョブ開始時に専用ブランチfilesystemからファイルシステムを復元する(その上にgit reset --hard)。
  2. inotifywaitでファイル変更を継続的に監視し、ファイルが動くたびに即コミットして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   # debounce
  done
}
  1. 各保存は新規コミットではなく直前のコミットをamendする(git commit --amend --no-edit)ので、filesystemブランチは常にコミット1つだけで、何千ものスナップショットが溜まらない。
  2. while trueループが、セッションが死んだらtmateを自動で再起動する。remain-on-exit onでexit後もターミナルに到達できるままにしておく。
  3. tmateが生成したSSH用URLはhost.confファイルに書き出してfilesystemブランチにコミットするので、ジョブのログをリアルタイムで見ていなくてもGitHub API(gh api .../contents/host.conf)経由で回収できる。
  4. inotifywaitがイベントを取りこぼした場合に備えて、periodic_saveルーチンがバックグラウンドで5秒ごとに回る。

結果として、完全なLinuxシェルにどこからでもアクセスでき、土台(GitHub Actionsランナー)はそんな用途をまったく想定していないのにファイルシステムはセッション間で永続化される。唯一の本当の制限は1ジョブ6時間のタイムアウトで、それを超えたらワークフローを再起動する必要がある。

5. 自分でプルリクエストを開くボット

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; // すでにオープンなPRあり

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に公開する

5つの中では一番目立たないが、おそらく将来に向けて一番重要だ。typescript-virtual-containerのpublish.ymlワークフローにはnpmシークレットが一切ない。NPM_TOKENもNODE_AUTH_TOKENもない。あるのはこれだけだ。

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は動く。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には明確な制限があり、それは意図的なものだ。 各runにGitHubが注入する自動トークンは強力だが、いくつかの点では封印されている。デフォルトでは他のワークフローを起動できないし、リポジトリ設定によってはブランチ保護ルールにブロックされることもある。create-pull-request.ymlが別のPAT(AUTO_PR_TOKEN)を必須とするのはまさにそのためだ。ジョブの一時トークンとは別に、本物のアカウント(またはGitHub App)由来のトークンで、明示的な権限contents:writeとpull-requests:writeを持つものが必要になる。

権限は全体ではなくジョブごとに絞る。 ここに挙げた各ワークフローは最小限のpermissions:ブロックを宣言してコメントを付けている。

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

デフォルトのGITHUB_TOKENは歴史的にパブリックリポジトリでかなり広い権限を持っていた。ジョブが本当に必要とする範囲に明示的に絞っておけば、チェーン内のサードパーティ製アクション(uses: 誰かの/action@version)の1つが侵害されたときの被害を抑えられる。

最高のシークレットは存在しないシークレットだ。 typescript-virtual-containerのOIDCパターンはこの考えの最良の形だ。NPM_TOKENのローテーション、有効期限、漏洩リスクを管理する代わりに、ワークフローは自分の身元(この正確なリポジトリ、この正確なワークフロー)をサードパーティサービスに暗号学的に証明する。AWS、Docker Hub、PyPIでも同じ仕組みが使える。GitHub ActionsからのOIDCに対応するレジストリやクラウドはどんどん増えている。


要点3つ

  1. gitタグ(orphan、force-push)はミニマルなデータベースにもプリコンパイル済みビルドキャッシュにもなる。同じ仕組みの別々の使い方だ。
  2. 無料のGitHub Actionsランナーも、ファイルシステムをgitブランチへ継続同期すれば永続SSHシェルになる。inotifywaitによる自動保存とamend1コミットが鍵だ。
  3. デフォルトのGITHUB_TOKENは意図的に制限されている。ブランチ間のPR作成やシークレットなし公開には、専用PATかOIDC trusted publishingへの移行が必要だ。

GitHub Actions를 비틀어 쓰는 5가지 방법 (그리고 시크릿에 대해 배우는 것)

무료 VPS가 된 CI 러너, 스스로 풀 리퀘스트를 여는 봇, 시크릿 없는 npm 게시. 단순한 lint와 test, deploy를 벗어난 GitHub Actions 패턴들을 제 저장소 순회로 정리해요.

GitHub Actions를 비틀어 쓰는 5가지 방법

GitHub Actions는 겉으로 보면 고전적인 CI와 CD용이에요. 푸시하면 린트하고 테스트하고 배포해요. 특별한 경우 하나는 이미 쓴 적이 있어요 (이메일 봇용 데이터베이스로 git 태그를 쓰는 것, 전용 글 참고). 하지만 제 저장소들을 뒤져 보니 하나의 프로젝트에 집중한 글보다 덜하고 기법 카탈로그에 가까운 별도 글로 쓸 만큼 패턴이 다양해요.

다섯 가지 용도, 가장 고전적인 것부터 가장 비튼 것까지요.

1. 두 실행 사이의 영속 상태로 쓰는 git 태그

빠른 복습인데, 자세한 내용은 email-autoreply 글이에요. GitHub Actions는 설계상 무상태예요. 매 실행이 깨끗한 기기에서 시작해요. 우회는 전용 git 태그에 값(ID, 타임스탬프, 작은 상태 무엇이든)을 저장하는 거예요. 브랜치에는 절대 저장하지 않아요.

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

# 상태 쓰기 (고아 브랜치, 커밋 하나, 태그 강제 푸시)
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도 비슷한 일(의존성 캐시)을 하지만, 버전이 고정된 산출물을 통째로 고정해서 명시적으로 가리키고 싶을 때는 단순히 npm install을 빠르게 하는 것보다 git 태그가 더 직접적이에요.

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 러너의 의도된 용도를 완전히 비틀어서 최대 6시간(작업 최대 한도) 동안 SSH로 닿는 무료 Linux 기기로 만들어요.

원리는 tmate(SSH나 브라우저로 공유할 수 있는 터미널 세션을 여는 tmux 포크)를 실행하는 것 말고는 거의 아무것도 하지 않는 작업이에요.

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   # debounce
  done
}
  1. 매 저장은 새로 만들지 않고 이전 커밋을 수정해요 (git commit --amend --no-edit). 그래서 filesystem 브랜치는 항상 커밋 하나를 유지하고, 수천 개 스냅샷이 쌓이지 않아요.
  2. while true 루프가 세션이 죽으면 tmate를 자동으로 다시 실행해요. exit 뒤에도 터미널에 닿도록 remain-on-exit on을 켜고요.
  3. tmate가 만든 SSH URL은 host.conf 파일에 쓰이고 filesystem 브랜치에 커밋돼서, 작업 로그에 직접 닿은 적 없이 GitHub API(gh api .../contents/host.conf)로 가져올 수 있어요.
  4. inotifywait가 이벤트를 놓칠 경우를 대비해 periodic_save 루틴이 5초마다 백그라운드로 돌아요.

결과: 파일시스템이 세션 사이에 유지되는 완전한 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; // 이미 열린 PR 있어요

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  # 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는 그래도 동작해요. npm 레지스트리가 이제 OIDC를 통한 트러스티드 퍼블리싱을 지원하니까요. 워크플로가 정적 토큰을 어디에도 주고받거나 저장하지 않고 레지스트리에 직접 신원을 증명해요 (npmjs.org 쪽에 설정된 정확한 저장소와 워크플로). 유출할 시크릿도 없고, 6개월마다 돌릴 토큰도 없어요.


GitHub 시크릿, 깊이 들어가기

이 다섯 가지 패턴은 어떤 식으로든 모두 시크릿 문제에 닿아요. 제 워크플로 도처에 돌아오는 원칙 몇 가지예요.

시크릿이 꼭 단순 문자열은 아니에요. email-autoreply에서 ACCOUNTS_JSON은 멀티 계정 설정의 minify된 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
  # Only allow writing checks, everything else is read-only

공개 저장소의 기본 GITHUB_TOKEN은 역사적으로 꽤 넓은 권한이 있어요. 작업에 실제로 필요한 것으로 명시적으로 좁히면 체인의 서드파티 액션(uses: quelqu-un/action@version) 중 하나가 손상됐을 때 피해를 제한해요.

가장 좋은 시크릿은 존재하지 않는 시크릿이에요. typescript-virtual-container의 OIDC 패턴이 이 생각의 가장 완성된 버전이에요. NPM_TOKEN의 교체와 만료, 유출 위험을 관리하는 대신, 워크플로가 제삼자 서비스에 직접 암호학적으로 신원을 증명해요 (바로 이 저장소, 바로 이 워크플로). AWS, Docker Hub, PyPI에도 같은 논리가 있어요. 점점 더 많은 레지스트리와 클라우드가 GitHub Actions의 OIDC를 지원해요.


핵심 3가지

  1. git 태그(고아, 강제 푸시)는 미니멀 데이터베이스나 미리 컴파일된 빌드 캐시로 쓸 수 있어요. 같은 메커니즘의 서로 다른 두 용도예요.
  2. git 브랜치에 파일시스템을 계속 동기화하면 무료 GitHub Actions 러너가 영속 셸이 될 수 있어요. inotifywait 자동 저장과 커밋 하나로 수정이요.
  3. 기본 GITHUB_TOKEN은 의도적으로 제한돼요. 브랜치 사이 PR 만들기나 시크릿 없는 게시는 전용 PAT나 OIDC 트러스티드 퍼블리싱으로 넘어가야 해요.

GitHub Actions'ı Kullanmanın 5 Zekice Yolu (ve sırlar hakkında öğrettikleri)

Ücretsiz VPS'e dönüşen bir CI runner, kendi pull requestlerini açan bir bot, tek bir sır bile olmadan npm yayını. Klasik 'lint + test + deploy' kalıbının dışına çıkan desenleri listelemek için depolarımda bir tur.

GitHub Actions'ı Kullanmanın 5 Zekice Yolu

GitHub Actions kağıt üzerinde klasik CI/CD için var: push yaparsın, lint çalışır, test edilir, dağıtılır. Özel bir durum hakkında zaten yazdım (bir e-posta botu için git taglerini veritabanı olarak kullanmak, ilgili makaleye bak). Ama kendi depolarımı karıştırınca, tek bir projeye odaklanan bir makaleden çok teknik kataloğuna benzeyecek kadar desen birikti.

En klasikten en çarpığına, beş kullanım.

1. İki run arasında kalıcı durum olarak bir git tagi

Hızlı bir hatırlatma, tüm detay email-autoreply makalesinde. GitHub Actions tasarım gereği stateless çalışır: her run tertemiz bir makineden başlar. Geçici çözüm: bir değeri (bir ID, bir zaman damgası, küçük bir durum) asla bir dalda değil, adanmış bir git taginde saklamak.

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

# durumu yaz (öksüz dal, tek bir commit, tagin force-pushu)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

Kilit nokta: hiç tarihçe biriktirmemek için öksüz bir dal, ve deponun dal listesini kirletmemek için dal yerine force uygulanan bir tag.

2. Önceden derlenmiş yapı önbelleği olarak bir git tagi

Aynı fikir ailesi, farklı kullanım: uygulama durumu saklamak yerine bir yapı artefaktı saklıyoruz. build işi kodu bir kez derler (master üzerine push yapılınca), sonra dist/ + node_modules/ içeriğini bir runtime tagine pushlar. cron işi ise bun install && bun run build çalıştırmak yerine doğrudan bu tagi checkout eder:

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

Bu, run süresini ~20 saniyeden ~10 saniyeye indiriyor. Sık çalışan bir cronda bu fark önemli. actions/cache benzer bir iş yapar (bağımlılıkları önbelleğe alır) ama bir git tagi, sadece bir npm install hızlandırmaktan çok sürümlü bir artefaktı doğrudan sabitleyip açıkça işaret etmek istediğinde daha nettir.

3. Birden çok işi toplayan tek bir zorunlu kontrol

Küçük görünen ama korumalı dal yapılandırmasında hayat değiştiren bir desen. konosuba-rpg üzerinde CI'ın paralel çalışan üç bağımsız işi var (typecheck, lint, tests) ve dördüncü bir iş, test-battery, üçüne bağımlı olmaktan başka bir şey yapmıyor:

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

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

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

Bunların içinde en çarpığı bu ve açık ara favorim: repo-to-vps, bir GitHub Actions runnerını baştan sona saptırıp en fazla 6 saate kadar (bir işin maksimum limiti) SSH ile erişilebilen ücretsiz bir Linux makinesine dönüştürüyor.

Prensip: tmate (SSH ya da tarayıcı üzerinden paylaşılabilir terminal oturumu açan bir tmux çatalı) çalıştırmaktan 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ş ağrısı, bir GitHub Actions runnerının dosya sisteminin atılabilir olması: iş biter bitmez her şey kaybolur. Saatlerce süren bir SSH oturumu, içinde yaptığın her şey bir sonraki run dawn siliniyorsa hiçbir işe yaramaz. Çözüm: dosya sisteminin canlı anlık görüntüsü olarak hizmet eden, sürekli senkronize edilen bir git dalı.

start-tmate.sh scripti sırayla şunları yapar:

  1. İş başlarken dosya sistemini filesystem adlı adanmış bir daldan geri yükler (git reset --hard ile üzerine).
  2. Dosya değişikliklerini inotifywait ile sürekli izler ve bir dosya oynadığı anda 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   # debounce
  done
}
  1. Her kayıt yeni bir commit oluşturmak yerine öncekini değiştirir (git commit --amend --no-edit), böylece filesystem dalı her zaman tek committe kalır ve binlerce anlık görüntünün birikmesi engellenir.
  2. Bir while true döngüsü, oturum ölürse tmatei otomatik olarak yeniden başlatır, remain-on-exit on ile terminal exit sonrasında bile ulaşılabilir kalır.
  3. tmate tarafından üretilen SSH URLsi host.conf adlı bir dosyaya yazılır, filesystem dalına commitlenir, yani GitHub API üzerinden alınabilir (gh api .../contents/host.conf), işin loglarına canlı erişmeye hiç gerek kalmaz.
  4. inotifywait bir olayı kaçırırsa diye 5 saniyede bir arka planda çalışan bir periodic_save rutini vardır.

Sonuç: altyapı (bir GitHub Actions runnerı) bunun için kesinlikle tasarlanmamışken, dosya sistemi oturumlar arasında kalıcı, her yerden erişilebilen tam bir Linux kabuğu. Tek gerçek limit iş başına 6 saatlik timeout: sonrasında workflowu yeniden başlatmak gerekir.

5. Kendi pull requestlerini açan bir bot

konosuba-rpg üzerinde dev dalına push yapmak, maine açık bir pull request olup olmadığını kontrol eden ve yoksa GitHub REST API üzerinden actions/github-script ile otomatik olarak oluşturan bir işi tetikler:

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

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

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. Bu workflow otomatik GITHUB_TOKENı kullanmıyor: ayrı bir AUTO_PR_TOKEN sırrı istiyor ve bu yoksa devam etmeyi reddediyor:

- 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. Hiçbir sır olmadan npm üzerinde yayınlamak

Beşinin içinde en göze çarpmayanı ama muhtemelen gelecek için en önemlisi: typescript-virtual-container deposunun publish.yml workflowu hiç npm sırrı içermiyor. NPM_TOKEN yok, NODE_AUTH_TOKEN yok. Sadece bu:

permissions:
  id-token: write  # OIDC için gerekli
  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ışıyor çünkü npm kayıt defteri artık OIDC üzerinden trusted publishing destekliyor: workflow kimliğini doğrudan kayıt defterine kanıtlıyor (npmjs.org tarafında yapılandırılmış tam depo + workflow), arada hiçbir statik token geçmiyor ve hiçbir yerde saklanmıyor. Sızdırılacak sıfır sır, altı ayda bir döndürülecek sıfır token.


GitHub sırları, derinlemesine

Bu beş desenin hepsi bir şekilde sırlar konusuna dokunuyor. Workflowlarımın hepsinde tekrar eden birkaç ilke:

Bir sır illa basit bir dize olmak zorunda değil. email-autoreply içinde ACCOUNTS_JSON, çok hesaplı yapılandırmanın minifiye edilmiş JSONunun tamamını içerir: basit bir API anahtarından çok, çalışma anında olduğu gibi bir dosyaya enjekte edilen tam bir veri yapısı:

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

Bu, şifreli bile olsa bir yapılandırma dosyasını commitlemekten kurtarır ve koda dokunmadan deponun ayarlarından tek tıkla güncellenir.

GITHUB_TOKENın kesin limitleri var ve bu istenerek yapılmış. GitHubın her runa enjekte ettiği otomatik token güçlüdür ama bazı noktalarda mühürlüdür: varsayılan olarak başka bir workflow tetikleyemez ve deponun yapılandırmasına göre dal koruma kurallarına takılabilir. create-pull-request.ymlin ayrı bir PAT (AUTO_PR_TOKEN) istemesi tam olarak bu yüzden: işin geçici tokenından farklı, açık contents:write + pull-requests:write haklarına sahip, gerçek bir hesaptan (ya da bir GitHub App üzerinden) gelen bir token.

İzinler global değil, iş iş kapsanır. Burada listelediğim her workflow minimal ve yorumlu bir permissions: bloğu bildirir:

permissions:
  contents: read
  actions: read
  checks: write
  # Sadece check yazmaya izin ver, geri kalan her şey salt okunur

Genel bir depoda varsayılan GITHUB_TOKEN tarihsel olarak oldukça geniş haklara sahiptir; onu işin gerçekten ihtiyaç duyduğuyla açıkça sınırlamak, zincirdeki üçüncü parti bir aksiyon (uses: birisi/action@sürüm) ele geçirilmiş çıkarsa zararı sınırlar.

En iyi sır var olmayan sırdır. typescript-virtual-containerın OIDC deseni bu fikrin en olgun hali: bir NPM_TOKENın rotasyonunu, süresini ve sızma riskini yönetmek yerine workflow kriptografik olarak kimliğini (tam olarak bu depo, tam olarak bu workflow) doğrudan üçüncü parti servise kanıtlıyor. Aynı mantık AWS, Docker Hub, PyPI için de mevcut: giderek daha fazla kayıt defteri ve bulut GitHub Actions üzerinden OIDC destekliyor.


3 kilit nokta

  1. Bir git tagi (öksüz, force-pushlanan) minimalist bir veritabanı ya da önceden derlenmiş yapı önbelleği olarak kullanılabilir: aynı mekanizmanın iki farklı kullanımı.
  2. Ücretsiz bir GitHub Actions runnerı, dosya sistemini bir git dalına sürekli senkronize etmeyi kabul edersen inotifywait ile otomatik kayıt ve tek bir değiştirilen commit ile kalıcı bir SSH kabuğuna dönüşebilir.
  3. Varsayılan GITHUB_TOKEN bilinçli olarak sınırlıdır: dallar arası PR oluşturmak ya da sırsız yayın yapmak ya adanmış bir PAT ya da OIDC trusted publishing geçişi gerektirir.

5 modi ingegnosi per usare GitHub Actions (e cosa insegnano sui secret)

Un runner CI trasformato in VPS gratuito, un bot che apre le sue pull request, un publish npm senza alcun secret. Giro dei miei repo per elencare i pattern GitHub Actions che vanno oltre il semplice \"lint + test + deploy\".

5 modi ingegnosi per usare GitHub Actions

GitHub Actions, sulla carta, serve per CI/CD classico: fai push, lui fa lint, testa, deploya. Ho già scritto di un caso particolare (usare i tag git 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 centrato su un solo progetto, più catalogo di tecniche.

Cinque usi, dal più classico al più strambo.

1. Un tag git come stato persistente tra due run

Promemoria veloce, il dettaglio completo è nell'articolo su email-autoreply. GitHub Actions è stateless per design: ogni run parte da una macchina pulita. La scappatoia: salvare un valore (un ID, un timestamp, un piccolo stato qualsiasi) in un tag git dedicato, mai in un branch.

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

# scrivi lo stato (branch orfano, 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

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

2. Un tag git come cache di build precompilata

Stessa famiglia di idea, altro uso: invece di salvare uno stato applicativo, salvi un artefatto di build. Il job build compila il codice una volta (su push verso master), poi spinge dist/ + node_modules/ in un tag runtime. Il job cron, invece, fa checkout direttamente di quel tag invece di lanciare 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

Cambia il run da circa 20s a circa 10s. Su un cron che gira spesso, conta. actions/cache fa un lavoro simile (mette in cache le dipendenze), ma un tag git è più diretto quando vuoi proprio congelare un artefatto versionato e puntarlo in modo esplicito, invece di accelerare solo un npm install.

3. Un solo check obbligatorio che aggrega più job

Piccolo pattern che sembra niente ma ti 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 di spuntare tre check obbligatori separati, e di 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 il dettaglio interno cambia.

4. Trasformare un runner gratuito in VPS temporaneo

Questo è il più strambo di tutti, e chiaramente il mio preferito: repo-to-vps stravolge completamente l'uso previsto di un runner GitHub Actions per farne una macchina Linux accessibile via SSH, gratuita, per fino a 6 ore (il limite massimo di un job).

Il principio: un job che non fa quasi altro che lanciare tmate (un fork di tmux che espone una sessione terminale condivisibile via SSH o 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

Il vero rompicapo è che il filesystem di un runner GitHub Actions è usa e getta: appena il job finisce, tutto sparisce. Una sessione SSH che dura ore non serve a niente se tutto quello che ci fai evapora al run successivo. La soluzione: un branch git che serve 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. Sorveglia i cambiamenti dei file con inotifywait in continuo, e fa commit + push immediato 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   # debounce
  done
}
  1. Ogni salvataggio fa amend del commit precedente invece di crearne uno nuovo (git commit --amend --no-edit), quindi il branch filesystem resta sempre a un solo commit, e questo evita l'accumulo di migliaia di snapshot.
  2. Un ciclo while true rilancia tmate automaticamente se la sessione muore, con remain-on-exit on così il terminale resta raggiungibile anche dopo un exit.
  3. L'URL SSH generato da tmate viene scritto in un file host.conf, committato sul branch filesystem, quindi recuperabile via API GitHub (gh api .../contents/host.conf) senza aver mai avuto accesso ai log del job in diretta.
  4. Una routine periodic_save gira ogni 5 secondi in background, nel caso inotifywait perda un evento.

Risultato: una shell Linux completa, accessibile da ovunque, con un filesystem che persiste tra le sessioni, mentre l'infrastruttura sotto (un runner GitHub Actions) non è assolutamente stata pensata per questo. L'unico vero limite è il timeout di 6h per job: dopo tocca rilanciare il workflow.

5. Un bot che apre le sue 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 altrimenti, via actions/github-script e API REST GitHub:

// confronta i commit tra base e head
const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return; // niente da proporre

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

// crea la PR automatica
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 secret separato AUTO_PR_TOKEN, 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 secret

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

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 funziona comunque, perché il registro npm ormai supporta il trusted publishing via OIDC: il workflow prova la sua identità direttamente al registro (repo + workflow esatti, configurati su npmjs.org), senza che alcun token statico transiti o venga salvato da nessuna parte. Zero secret da far trapelare, zero token da ruotare ogni sei mesi.


I secret GitHub, in profondità

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

Un secret non è per forza una semplice stringa. In email-autoreply, ACCOUNTS_JSON contiene l'intero JSON minimizzato della config multi-account: una struttura dati completa invece di una semplice chiave API, iniettata così com'è in un file al runtime:

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

Evita di dover committare un file di config, anche cifrato, e si aggiorna in 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 alcuni punti: di default non può attivare un altro workflow, e secondo la configurazione del repo può essere bloccato dalle regole di protezione dei branch. È esattamente per questo che create-pull-request.yml richiede un PAT separato (AUTO_PR_TOKEN): un token che viene da un vero account (o da una GitHub App), con diritti espliciti contents:write + pull-requests:write, distinto dal token effimero del job.

I permessi si definiscono 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
  # Only allow writing checks, everything else is read-only

Il GITHUB_TOKEN di default ha storicamente diritti abbastanza ampi su un repo pubblico; restringerlo in modo esplicito a ciò di cui il job ha davvero bisogno limita i danni se un'azione di terzi nella catena (uses: qualcuno/action@versione) si rivela compromessa.

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


3 punti chiave

  1. Un tag git (orfano, force-push) può servire da database minimalista o da 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 il suo filesystem in continuo verso un branch git, con autosave via inotifywait e un solo commit in amend.
  3. Il GITHUB_TOKEN di default è volutamente limitato: creare PR tra branch o pubblicare senza secret richiede o un PAT dedicato, o il passaggio a OIDC trusted publishing.

5 zweckentfremdete Arten, GitHub Actions zu nutzen (und was das über Secrets lehrt)

Ein CI-Runner verwandelt in einen gratis VPS, ein Bot, der seine eigenen Pull Requests öffnet, ein npm-Publish ohne das kleinste Secret. Tour durch meine Repos, um die GitHub-Actions-Patterns zu listen, die aus simplem \"lint + test + deploy\" ausbrechen.

5 zweckentfremdete Arten, GitHub Actions zu nutzen

GitHub Actions, auf dem Papier, ist für klassisches CI/CD gemacht: Du pushst, es lintet, es testet, es deployed. Ich habe schon über einen besonderen Fall geschrieben (Git-Tags als Datenbank für einen Mailbot nutzen, siehe den dedizierten Artikel). Aber beim Stöbern in meinen eigenen Repos gibt es genug unterschiedliche Patterns, um einen eigenen Artikel zu rechtfertigen, weniger auf ein einzelnes Projekt zentriert, mehr Technikkatalog.

Fünf Nutzungen, von der klassischsten bis zur verdrehtesten.

1. Ein Git-Tag als persistenter Zustand zwischen zwei Runs

Kurze Erinnerung, das komplette Detail steht im Artikel über email-autoreply. GitHub Actions ist by Design stateless: Jeder Run startet auf einer jungfräulichen Maschine. Der Workaround: Einen Wert speichern (eine ID, einen Timestamp, irgendeinen kleinen Zustand) in einem dedizierten Git-Tag, niemals in einem Branch.

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

# Zustand schreiben (verwaister Branch, ein einziger Commit, Force-Push des Tags)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

Der springende Punkt: Ein verwaister Branch, um niemals Historie anzuhäufen, und ein forcierter Tag statt eines Branches, um die Branchliste des Repos nicht zu verschmutzen.

2. Ein Git-Tag als vorkompilierter Build-Cache

Gleiche Ideenfamilie, andere Nutzung: Statt eines Applikationszustands speichert man ein Build-Artefakt. Der build-Job kompiliert den Code einmal (bei Push auf master) und pusht dann dist/ + node_modules/ in einen runtime-Tag. Der cron-Job dagegen checkt direkt diesen Tag aus, statt bei jeder Ausführung bun install && bun run build laufen 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 schon bereit
- run: node dist/index.js --action

Das ändert den Run von ~20s auf ~10s. Bei einem Cron, der oft läuft, zählt das. actions/cache macht eine ähnliche Arbeit (Abhängigkeiten cachen), aber ein Git-Tag ist direkter, wenn du gleich ein versioniertes Artefakt einfrieren und explizit darauf zeigen willst, statt einfach ein npm install zu beschleunigen.

3. Ein einziger Pflicht-Check, der mehrere Jobs aggregiert

Kleines Pattern, das nach nichts aussieht, aber das Leben in der Protected-Branch-Konfiguration ändert. Auf 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 würde das Konfigurieren eines Protected Branches erfordern, drei separate Pflicht-Checks anzukreuzen und diese Liste jedes Mal zu aktualisieren, wenn ein Job hinzugefügt oder umbenannt wird. Mit test-battery ein einziger anzukreuzender Name in den Repo-Einstellungen, der stabil bleibt, selbst wenn das interne Detail sich ändert.

4. Einen gratis Runner in einen temporären VPS verwandeln

Der ist der verdrehteste von allen und klar mein Favorit: repo-to-vps entfremdet die vorgesehene Nutzung eines GitHub-Actions-Runners komplett, um daraus eine per SSH erreichbare Linux-Maschine zu machen, gratis, für bis zu 6 Stunden (das Job-Maximum).

Das Prinzip: Ein Job, der fast nichts anderes tut, als tmate zu starten (ein tmux-Fork, der eine per SSH oder Browser teilbare Terminalsession exponiert):

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

Der echte Knackpunkt ist, dass das Dateisystem eines GitHub-Actions-Runners wegwerfbar ist: Sobald der Job endet, verschwindet alles. Eine SSH-Session, die Stunden dauert, nützt nichts, wenn alles, was du darin tust, beim nächsten Run verdunstet. Die Lösung: Ein Git-Branch, der als Live-Snapshot des Dateisystems dient, kontinuierlich synchronisiert.

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

  1. Stellt beim Jobstart das Dateisystem aus einem dedizierten filesystem-Branch wieder her (git reset --hard darauf).
  2. Überwacht Dateiänderungen kontinuierlich mit inotifywait und committet + pusht sofort, sobald sich eine Datei 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   # debounce
  done
}
  1. Jede Sicherung amendiert den vorherigen Commit, statt einen neuen zu erstellen (git commit --amend --no-edit), also bleibt der filesystem-Branch immer bei einem einzigen Commit, was die Anhäufung Tausender Snapshots vermeidet.
  2. Eine while true-Schleife startet tmate automatisch neu, wenn die Session stirbt, mit remain-on-exit on, damit das Terminal selbst nach einem exit erreichbar bleibt.
  3. Die von tmate generierte SSH-URL wird in eine Datei host.conf geschrieben, auf den filesystem-Branch committet, also via GitHub-API abrufbar (gh api .../contents/host.conf), ohne je live Zugriff auf die Joblogs gehabt zu haben.
  4. Eine periodic_save-Routine läuft alle 5 Sekunden im Hintergrund, falls inotifywait ein Event verpassen sollte.

Ergebnis: Eine komplette Linux-Shell, von überall erreichbar, mit einem Dateisystem, das zwischen Sessions persistiert, während die darunterliegende Infrastruktur (ein GitHub-Actions-Runner) absolut nicht dafür entworfen wurde. Die einzige echte Grenze ist das 6h-Timeout pro Job: Danach muss man den Workflow neu starten.

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

Auf konosuba-rpg löst ein Push auf den dev-Branch einen Job aus, der prüft, ob schon ein offener Pull Request nach main existiert, und sonst automatisch einen erstellt, via actions/github-script und GitHub-REST-API:

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

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

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

Das Detail, das hier zählt, ist der genutzte Token. Dieser Workflow nutzt nicht den automatischen 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 publizieren ohne ein einziges Secret

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

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 funktioniert trotzdem, weil die npm-Registry inzwischen Trusted Publishing via OIDC unterstützt: Der Workflow beweist seine Identität direkt bei der Registry (exaktes Repo + exakter Workflow, konfiguriert auf npmjs.org-Seite), ohne dass irgendein statischer Token transitiert oder irgendwo gespeichert wird. Null Secrets zum Leaken, null Tokens zum alle sechs Monate rotieren.


Die GitHub-Secrets, in der Tiefe

Diese fünf Patterns berühren alle auf die eine oder andere Art die Secret-Frage. Einige Prinzipien, die überall in meinen Workflows wiederkehren:

Ein Secret ist nicht unbedingt eine simple Zeichenkette. In email-autoreply enthält ACCOUNTS_JSON das gesamte minifizierte JSON der Multi-Account-Config: Eine komplette Datenstruktur statt eines simplen API-Schlüssels, zur Runtime so wie sie ist 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 es aktualisiert sich mit einem Klick in den Repo-Settings, ohne den Code anzufassen.

GITHUB_TOKEN hat präzise Grenzen, und das ist gewollt. Der automatische Token, den GitHub bei jedem Run injiziert, ist mächtig, aber an manchen Punkten versiegelt: Standardmäßig kann er keinen anderen Workflow triggern, und je nach Repo-Konfiguration kann er von Branch-Protection-Regeln blockiert werden. Genau deshalb verlangt create-pull-request.yml einen separaten PAT (AUTO_PR_TOKEN): Einen Token, der von einem echten Konto (oder einer GitHub App) kommt, mit expliziten contents:write + pull-requests:write-Rechten, getrennt vom ephemeren Job-Token.

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

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

Der standardmäßige GITHUB_TOKEN hat historisch ziemlich breite Rechte auf einem öffentlichen Repo; ihn explizit auf das zu beschränken, was der Job wirklich braucht, begrenzt den Schaden, falls sich eine Drittanbieter-Action in der Kette (uses: irgend-wer/action@version) als kompromittiert erweist.

Das beste Secret ist das, das nicht existiert. Das OIDC-Pattern von typescript-virtual-container ist die ausgereifteste Version dieser Idee: Statt Rotation, Ablauf und Leak-Risiko eines NPM_TOKEN zu verwalten, beweist der Workflow seine Identität kryptografisch (genau dieses Repo, genau dieser Workflow) direkt beim Drittdienst. Dieselbe Logik verfügbar für AWS, Docker Hub, PyPI: Immer mehr Registries und Clouds unterstützen OIDC von GitHub Actions.


3 Kernpunkte

  1. Ein Git-Tag (verwaist, force-gepusht) kann als minimalistische Datenbank oder als vorkompilierter Build-Cache dienen: Zwei unterschiedliche Nutzungen desselben Mechanismus.
  2. Ein gratis GitHub-Actions-Runner kann zu einer persistenten SSH-Shell werden, wenn man akzeptiert, sein Dateisystem kontinuierlich auf einen Git-Branch zu synchronisieren, mit Autosave per inotifywait und einem einzigen amendierten Commit.
  3. Der standardmäßige GITHUB_TOKEN ist bewusst begrenzt: PRs zwischen Branches erstellen oder ohne Secret publizieren verlangt entweder einen dedizierten PAT oder einen Wechsel zu OIDC-Trusted-Publishing.

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

CI-раннер, превращённый в бесплатный VPS, бот, открывающий собственные пул-реквесты, npm-публикация без единого секрета. Обход моих репозиториев ради списка паттернов GitHub Actions за пределами простого «линт + тест + деплой».

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

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

Пять применений, от самого классического до самого кривого.

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

Быстрое напоминание, полная деталь в статье про email-autoreply. GitHub Actions stateless по дизайну : каждый запуск стартует с чистой машины. Обход : хранить значение (ID, timestamp, любое мелкое состояние) в выделенном 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 компилирует код один раз (на push в 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

Это меняет запуск с ~20s на ~10s. На 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, делая из него доступную по SSH машину Linux, бесплатную, до 6 часов (максимальный лимит джобы).

Принцип : джоба, почти ничего не делающая, кроме запуска 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 одноразовая : как только джоба завершается, всё исчезает. 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   # debounce
  done
}
  1. Каждое сохранение аммендит предыдущий коммит вместо создания нового (git commit --amend --no-edit), так что ветка filesystem всегда остаётся на одном коммите, что избегает накопления тысяч снапшотов.
  2. Цикл while true перезапускает tmate автоматически, если сессия умерла, с remain-on-exit on, чтобы терминал оставался доступен даже после exit.
  3. SSH-URL, сгенерированный tmate, пишется в файл host.conf, коммитящийся в ветку filesystem, так что достаётся через API GitHub (gh api .../contents/host.conf), ни разу не имея доступа к логам джобы вживую.
  4. Рутина periodic_save крутится каждые 5 секунд в фоне, на случай если inotifywait пропустит событие.

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

5. Бот, открывающий собственные пул-реквесты

На konosuba-rpg push в ветку dev триггерит джобу, проверяющую, есть ли уже открытый пул-реквест в main, и автоматически создающую его, если нет, через actions/github-script и REST API 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; // уже есть открытый PR

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  # 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 всё равно работает, потому что реестр npm теперь поддерживает trusted publishing через OIDC : воркфлоу доказывает свою идентичность прямо реестру (точные репо + воркфлоу, настроенные на стороне npmjs.org), без того чтобы какой-либо статичный токен где-то ходил или хранился. Ноль секретов для утечки, ноль токенов для ротации каждые полгода.


Секреты 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
  # Only allow writing checks, everything else is read-only

Дефолтный GITHUB_TOKEN исторически имеет довольно широкие права на публичном репо ; явное ограничение до реально нужного джобе лимитирует ущерб, если сторонний экшен в цепочке (uses: quelqu-un/action@version) окажется скомпрометирован.

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


3 ключевых пункта

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

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

Un runner CI convertido en VPS gratis, un bot que abre sus propias pull requests, un publish npm sin el menor secret. Recorrido por mis repos para listar los patrones GitHub Actions que salen del simple \"lint + test + deploy\".

5 formas retorcidas de usar GitHub Actions

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

Cinco usos, del más clásico al más retorcido.

1. Un tag git como estado persistente entre dos runs

Recordatorio rápido, el detalle completo está en el artículo sobre email-autoreply. GitHub Actions es stateless por diseño: cada run parte de una máquina virgen. El rodeo: guardar un valor (un ID, un timestamp, cualquier estado pequeño) en un tag git dedicado, nunca en una rama.

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

# escribir el 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 no acumular nunca historial, y un tag forzado en vez de una rama para no contaminar la lista de ramas del repo.

2. Un tag git como caché de build precompilado

Misma familia de idea, otro uso: en vez de guardar un estado aplicativo, guardamos un artefacto de build. El job build compila el código una vez (en push hacia master), y luego empuja dist/ + node_modules/ en un tag runtime. El job cron, por su parte, hace checkout directamente de ese tag en vez de correr bun install && bun run build en cada ejecución:

- 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

Eso cambia el run de ~20s a ~10s. En un cron que corre a menudo, cuenta. actions/cache hace un trabajo similar (cachear dependencias), pero un tag git es más directo cuando quieres directamente congelar un artefacto versionado y apuntarlo explícitamente, en vez de simplemente acelerar un npm install.

3. Un solo check obligatorio que agrega varios jobs

Pequeño patrón que parece nada pero que cambia la vida en configuración de rama protegida. 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 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 ese job fachada, configurar una rama protegida exigirí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, que queda estable aunque el detalle interno cambie.

4. Transformar un runner gratis en VPS temporal

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

El principio: un job que no hace casi nada más que lanzar tmate (un fork de tmux que expone una sesión de terminal compartible por SSH o navegador):

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 rompecabezas es que el filesystem de un runner 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 en ella se evapora en el próximo run. La solución: una rama git que sirve de snapshot en vivo del filesystem, sincronizada en continuo.

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

  1. Restaura el filesystem desde una rama dedicada filesystem al arrancar el job (git reset --hard sobre ella).
  2. Vigila los cambios de archivos con inotifywait en continuo, y hace commit + push de inmediato 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   # debounce
  done
}
  1. Cada guardado enmienda el commit anterior en vez de crear uno nuevo (git commit --amend --no-edit), así la rama filesystem siempre queda en un solo commit, lo que evita la 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 la terminal siga alcanzable incluso tras un exit.
  3. La URL SSH generada por tmate se escribe en un archivo host.conf, commiteado en la rama filesystem, así recuperable vía la API de GitHub (gh api .../contents/host.conf) sin haber tenido nunca acceso a los logs del job en directo.
  4. Una rutina periodic_save corre cada 5 segundos en segundo plano, por si inotifywait pierde un evento.

Resultado: un shell Linux completo, accesible desde cualquier sitio, con un filesystem que persiste entre sesiones, aunque la infraestructura subyacente (un runner GitHub Actions) no se diseñó en absoluto para eso. El único verdadero límite es el timeout de 6h por job: después hay que relanzar el workflow.

5. Un bot que abre sus propias pull requests

En konosuba-rpg, un push en la rama dev dispara un job que comprueba si ya existe una pull request abierta hacia main, y crea una automáticamente si no, vía 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; // nada que proponer

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

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

El detalle que cuenta aquí es el token usado. Este workflow NO usa el GITHUB_TOKEN automático: exige un secret AUTO_PR_TOKEN separado, 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 secret

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 secret npm. Ningún NPM_TOKEN, ningún NODE_AUTH_TOKEN. Solo esto:

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 funciona igual, porque el registro npm ya soporta el trusted publishing vía OIDC: el workflow prueba su identidad directamente ante el registro (repo + workflow exactos, configurados en npmjs.org), sin que ningún token estático transite ni se guarde en ningún sitio. Cero secrets que filtrar, cero tokens que rotar cada seis meses.


Los secrets de GitHub, en profundidad

Esos cinco patrones tocan todos, de un modo u otro, la cuestión de los secrets. Algunos principios que se repiten en todas partes en mis workflows:

Un secret no es forzosamente una cadena simple. En email-autoreply, ACCOUNTS_JSON contiene el JSON minificado entero de la config multicuenta: una estructura de datos completa en vez de una simple clave API, inyectada tal cual en un archivo en runtime:

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

Eso evita tener que commitear un archivo de config, aunque sea cifrado, y se actualiza en 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 run es potente, pero sellado en ciertos puntos: por defecto, no puede disparar otro workflow, y según la configuración del repo puede quedar bloqueado por reglas de protección de rama. Es exactamente por eso que create-pull-request.yml exige un PAT (AUTO_PR_TOKEN) separado: un token que viene 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 acotan job por job, no globalmente. Cada workflow que listé aquí declara un bloque permissions: mínimo y comentado:

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

El GITHUB_TOKEN por defecto tiene históricamente derechos bastante amplios en un repo público; restringirlo explícitamente a lo que el job necesita de verdad limita los daños si una acción de terceros en la cadena (uses: alguien/action@versión) resulta comprometida.

El mejor secret es el que no existe. El patrón OIDC de typescript-virtual-container es la versión más lograda de esa idea: en vez de gestionar la rotación, la expiración y el riesgo de fuga de un NPM_TOKEN, el workflow prueba criptográficamente su identidad (ese repo preciso, ese workflow preciso) directamente ante el 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 tag git (huérfano, con force-push) puede servir de base de datos minimalista o de caché de build precompilado: dos usos distintos del mismo mecanismo.
  2. Un runner GitHub Actions gratis puede convertirse en un shell SSH persistente si aceptas sincronizar su filesystem en continuo hacia una rama git, con autoguardado por inotifywait y un solo commit enmendado.
  3. El GITHUB_TOKEN por defecto está deliberadamente limitado: crear PRs entre ramas o publicar sin secret exige o bien un PAT dedicado, o bien pasar al trusted publishing OIDC.

5 jeitos criativos de usar GitHub Actions (e o que isso ensina sobre secrets)

Um runner de CI transformado em VPS gratuita, um bot que abre seus próprios pull requests, um publish no npm sem nenhum secret. Tour pelos meus repos para listar os padrões de GitHub Actions que saem do simples \"lint + test + deploy\".

5 jeitos criativos de usar GitHub Actions

GitHub Actions, no papel, é feito para CI/CD clássico : você dá push, ele faz lint, testa, implanta. Já escrevi sobre um caso específico (usar tags git como banco de dados para um bot de email, ver o artigo dedicado). Mas fuçando nos meus próprios repos, há padrões diferentes o bastante para valer um artigo à parte, menos centrado em um único projeto, mais catálogo de técnicas.

Cinco usos, do mais clássico ao mais estranho.

1. Uma tag git como estado persistente entre dois runs

Lembrete rápido, o detalhe completo está no artigo sobre email-autoreply. GitHub Actions é stateless por design : cada run parte de uma máquina limpa. A solução alternativa : guardar um valor (um ID, um timestamp, qualquer pequeno estado) em uma tag git dedicada, nunca em um branch.

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

# escrever o estado (branch órfão, um único 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 principal : 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 tag git como cache de build pré-compilado

Mesma família de ideia, outro uso : em vez de guardar um estado de aplicativo, guardamos um artefato de build. O job build compila o código uma vez (em push para master), e depois envia dist/ + node_modules/ para uma tag runtime. O job cron, por sua vez, faz checkout direto dessa tag em vez de rodar 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

Isso muda o run de ~20s para ~10s. Em um cron que roda sempre, isso conta. O actions/cache faz um trabalho parecido (guardar dependências em cache), mas uma tag git é mais direta quando você quer mesmo congelar um artefato versionado e apontar explicitamente para ele, em vez de só acelerar um npm install.

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

Padrão pequeno que parece nada mas muda a vida na configuração de branch protegido. No konosuba-rpg, a CI tem três jobs independentes (typecheck, lint, tests) que rodam 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 esse job de 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 test-battery, um só nome para marcar nas configurações do repo, que continua estável mesmo que o detalhe interno mude.

4. Transformar um runner gratuito em VPS temporária

Esse é o mais estranho de todos, e claramente meu favorito : o repo-to-vps desvia completamente o uso previsto de um runner GitHub Actions para fazer dele uma máquina Linux acessível via SSH, gratuita, por até 6 horas (o limite máximo de um job).

O princípio : um job que quase não faz nada além de lançar o tmate (um fork do tmux que expõe uma sessão de terminal compartilhável via SSH ou navegador) :

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 quebra-cabeça é que o filesystem de um runner GitHub Actions é descartável : assim que o job termina, tudo some. Uma sessão SSH de horas não serve para nada se tudo que você faz nela evapora no próximo run. A solução : um branch git que serve de snapshot ao vivo do filesystem, sincronizado continuamente.

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

  1. Restaura o filesystem a partir de um branch dedicado filesystem no início do job (git reset --hard nele).
  2. Observa as mudanças de arquivos com inotifywait continuamente, e faz commit + push na hora assim que um arquivo muda :
# Observa mudanças e salva na hora (com debounce de 1s)
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. Cada salvamento altera o commit anterior em vez de criar um novo (git commit --amend --no-edit), então o branch filesystem fica sempre com um único commit, o que evita acumular milhares de snapshots.
  2. Um loop while true relança o tmate automaticamente se a sessão morrer, com remain-on-exit on para o terminal continuar acessível mesmo após um exit.
  3. A URL SSH gerada pelo tmate é escrita em um arquivo host.conf, commitado no branch filesystem, então recuperável via API do GitHub (gh api .../contents/host.conf) sem nunca ter tido acesso aos logs do job ao vivo.
  4. Uma rotina periodic_save roda a cada 5 segundos em segundo plano, caso o inotifywait perca algum evento.

Resultado : um shell Linux completo, acessível de qualquer lugar, com um filesystem que persiste entre sessões, enquanto a infraestrutura por baixo (um runner GitHub Actions) não foi absolutamente feita para isso. O único limite real é o timeout de 6h por job : depois disso é preciso relançar o workflow.

5. Um bot que abre seus próprios pull requests

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

// Compara dev com main, verifica PR aberta, cria uma se preciso
const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return; // nada a propor

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

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

O detalhe que conta aqui é o token usado. Esse workflow não usa o GITHUB_TOKEN automático : exige um secret AUTO_PR_TOKEN separado, e se recusa a continuar se ele estiver ausente :

# Valida que o token dedicado de PR existe antes de continuar
- 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 nenhum secret

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. Nenhum NPM_TOKEN, nenhum NODE_AUTH_TOKEN. Só isso :

# Publicação confiável via OIDC : sem token npm, identidade provada ao registro
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 funciona mesmo assim, porque o registro npm agora suporta trusted publishing via OIDC : o workflow prova sua identidade diretamente ao registro (repo + workflow exatos, configurados no lado npmjs.org), sem nenhum token estático transitar nem ser guardado em lugar algum. Zero secret para vazar, zero token para girar a cada seis meses.


Os secrets do GitHub, a fundo

Esses cinco padrões tocam todos, de um jeito ou de outro, na questão dos secrets. Alguns princípios que voltam em todos os meus workflows :

Um secret não é necessariamente uma string simples. No email-autoreply, ACCOUNTS_JSON contém o JSON minificado inteiro da config multicontas : uma estrutura de dados completa em vez de uma simples chave de API, injetada como está em um arquivo no runtime :

# Injeta a config multicontas inteira a partir de um único secret
env:
  ACCOUNTS_JSON: ${{ secrets.ACCOUNTS_JSON }}
run: printf "%s" "$ACCOUNTS_JSON" > data/accounts.json

Isso evita ter que commitar um arquivo de config, mesmo criptografado, e se atualiza em um clique nas settings do repo sem tocar no código.

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

As permissões têm escopo job por job, não global. Cada workflow que listei aqui declara um bloco permissions: mínimo e comentado :

# Escopo mínimo : só permite escrever checks, resto só leitura
permissions:
  contents: read
  actions: read
  checks: write
  # Only allow writing checks, everything else is read-only

O GITHUB_TOKEN padrão historicamente tem direitos bem amplos em um repo público ; restringi-lo explicitamente ao que o job realmente precisa limita os danos se uma action de terceiros na cadeia (uses: alguem/action@version) se revelar comprometida.

O melhor secret é o que não existe. O padrão OIDC do typescript-virtual-container é a versão mais acabada dessa ideia : em vez de gerenciar rotação, expiração e risco de vazamento de um NPM_TOKEN, o workflow prova criptograficamente 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 registros e clouds suportam OIDC a partir de GitHub Actions.


3 pontos principais

  1. Uma tag git (órfã, com force-push) pode servir de banco de dados minimalista ou de cache de build pré-compilado : dois usos distintos do mesmo mecanismo.
  2. Um runner gratuito de GitHub Actions pode virar um shell SSH persistente se aceitarmos sincronizar seu filesystem continuamente para um branch git, com autosave por inotifywait e um único commit alterado.
  3. O GITHUB_TOKEN padrão é propositalmente limitado : criar PRs entre branches ou publicar sem secret exige um PAT dedicado ou a passagem para OIDC trusted publishing.

5 cara cerdik memakai GitHub Actions (dan apa yang ia ajarkan soal secret)

Runner CI yang diubah menjadi VPS gratis, bot yang membuka pull request-nya sendiri, publish npm tanpa secret sedikit pun. Tur repo saya untuk mendaftar pola GitHub Actions yang keluar dari sekadar lint + test + deploy.

5 cara cerdik memakai GitHub Actions

GitHub Actions, di atas kertas, dibuat untuk CI/CD klasik : kamu push, ia lint, ia test, ia deploy. Saya sudah menulis soal kasus khusus (memakai tag git sebagai basis data untuk bot email, lihat artikel khusus). Tapi dengan menggali repo saya sendiri, ada cukup banyak pola berbeda sampai layak jadi artikel tersendiri, kurang terpusat pada satu proyek, lebih katalog teknik.

Lima kegunaan, dari paling klasik hingga paling bengkok.

1. Tag git sebagai keadaan persisten antar run

Pengingat cepat, detail lengkapnya ada di artikel email-autoreply. GitHub Actions stateless secara desain : setiap run berangkat dari mesin perawan. Akal-akalannya : menyimpan nilai (ID, timestamp, keadaan kecil apa pun) dalam tag git khusus, tidak pernah di branch.

# membaca keadaan
git show refs/tags/lastid:data/lastId > data/lastId

# menulis keadaan (branch yatim, satu commit, force-push tag)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

Poin kuncinya : branch yatim supaya tidak pernah menumpuk riwayat, dan tag yang dipaksa alih-alih branch supaya tidak mengotori daftar branch repo.

2. Tag git sebagai cache build pra-kompilasi

Keluarga ide yang sama, kegunaan lain : alih-alih menyimpan keadaan aplikatif, kita menyimpan artefak build. Job build mengompilasi kode sekali (saat push ke master), lalu mendorong dist/ + node_modules/ ke tag runtime. Job cron, checkout langsung tag ini alih-alih menjalankan bun install && bun run build di setiap eksekusi :

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

Ini mengubah run dari ~20 detik menjadi ~10 detik. Pada cron yang sering berjalan, itu penting. actions/cache melakukan pekerjaan serupa (meng cache dependensi), tapi tag git lebih langsung ketika kamu ingin membekukan artefak berversi dan menunjuknya eksplisit, alih-alih sekadar mempercepat npm install.

3. Satu cek wajib yang mengagregasi banyak job

Pola kecil yang tampak sepele tapi mengubah hidup dalam konfigurasi branch terproteksi. 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 yang 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 terproteksi menuntut mencentang tiga cek wajib terpisah, dan memperbarui daftar itu setiap kali job ditambahkan atau diganti nama. Dengan test-battery, satu nama yang dicentang di pengaturan repo, yang tetap stabil meski detail internal berubah.

4. Mengubah runner gratis menjadi VPS sementara

Yang ini paling bengkok dari semuanya, dan jelas favorit saya : repo-to-vps mengalihkan sepenuhnya kegunaan yang dimaksud dari runner GitHub Actions untuk menjadikannya mesin Linux yang bisa diakses SSH, gratis, hingga 6 jam (batas maks satu job).

Prinsipnya : job yang hampir tidak melakukan apa pun selain menjalankan tmate (fork tmux yang mengekspos sesi terminal yang bisa dibagikan lewat SSH atau peramban) :

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

Teka-teki sesungguhnya adalah filesystem runner GitHub Actions itu sekali pakai : begitu job selesai, semuanya hilang. Sesi SSH yang bertahan berjam-jam tidak ada gunanya kalau semua yang kamu lakukan di sana menguap di run berikutnya. Solusinya : branch git yang berfungsi sebagai snapshot live filesystem, disinkronkan terus-menerus.

Skrip start-tmate.sh melakukan, berurutan :

  1. Memulihkan filesystem dari branch khusus filesystem saat job dimulai (git reset --hard ke sana).
  2. Mengawasi perubahan berkas dengan inotifywait terus-menerus, dan commit + push segera begitu berkas bergerak :
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. Setiap simpanan meng amend commit sebelumnya alih-alih membuat yang baru (git commit --amend --no-edit), jadi branch filesystem selalu tetap satu commit, yang mencegah akumulasi ribuan snapshot.
  2. Loop while true menyalakan ulang tmate otomatis kalau sesi mati, dengan remain-on-exit on supaya terminal tetap bisa dijangkau bahkan setelah exit.
  3. URL SSH yang dibangkitkan tmate ditulis dalam berkas host.conf, yang di commit ke branch filesystem, jadi bisa diambil lewat API GitHub (gh api .../contents/host.conf) tanpa pernah punya akses ke log job secara langsung.
  4. Rutin periodic_save berjalan setiap 5 detik di latar belakang, seandainya 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 itu. Satu-satunya batas nyata adalah timeout 6 jam per job : setelah itu workflow harus dijalankan ulang.

5. Bot yang membuka pull request-nya sendiri

Di konosuba-rpg, push ke branch dev memicu job yang memeriksa apakah sudah ada pull request terbuka ke main, dan otomatis membuatnya kalau belum, lewat actions/github-script dan API REST GitHub :

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

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

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 dipakai. Workflow ini tidak memakai GITHUB_TOKEN otomatis : ia menuntut secret terpisah AUTO_PR_TOKEN, dan menolak lanjut kalau 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. Menerbitkan ke npm tanpa secret apa pun

Yang paling diam dari kelimanya, tapi mungkin paling penting untuk masa depan : workflow publish.yml dari typescript-virtual-container tidak berisi secret npm apa pun. Tanpa NPM_TOKEN, tanpa NODE_AUTH_TOKEN. Hanya ini :

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 tetap berhasil, karena registri npm kini mendukung trusted publishing lewat OIDC : workflow membuktikan identitasnya langsung ke registri (repo + workflow yang persis, dikonfigurasi di sisi npmjs.org), tanpa token statis apa pun transit maupun disimpan di mana pun. Nol secret yang bisa bocor, nol token yang diputar setiap enam bulan.


Secret GitHub, secara mendalam

Kelima pola ini semuanya menyentuh soal secret, dengan satu atau lain cara. Beberapa prinsip yang kembali di semua workflow saya :

Secret belum tentu string sederhana. Di email-autoreply, ACCOUNTS_JSON berisi JSON minifikasi utuh dari config multi-akun : struktur data lengkap alih-alih sekadar kunci API, disuntikkan apa adanya ke berkas saat runtime :

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

Ini menghindari keharusan meng commit berkas config, bahkan yang terenkripsi, dan diperbarui dalam sekali klik di pengaturan repo tanpa menyentuh kode.

GITHUB_TOKEN punya batas tepat, dan itu disengaja. Token otomatis yang disuntikkan GitHub ke setiap run itu kuat, tapi tersegel pada poin tertentu : secara bawaan, ia tidak bisa memicu workflow lain, dan tergantung konfigurasi repo ia bisa diblokir oleh aturan proteksi branch. Tepat karena itulah create-pull-request.yml menuntut PAT terpisah (AUTO_PR_TOKEN) : token yang berasal dari akun sungguhan (atau GitHub App), dengan hak eksplisit contents:write + pull-requests:write, berbeda dari token fana job.

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

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

GITHUB_TOKEN bawaan secara historis punya hak cukup luas di repo publik ; membatasinya eksplisit pada apa yang benar-benar dibutuhkan job membatasi kerusakan kalau action pihak ketiga dalam rantai (uses: seseorang/action@versi) ternyata dikompromikan.

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


3 poin kunci

  1. Tag git (yatim, di force-push) bisa berfungsi sebagai basis data minimalis atau cache build pra-kompilasi : dua kegunaan berbeda dari mekanisme yang sama.
  2. Runner GitHub Actions gratis bisa menjadi shell SSH persisten kalau kita menerima menyinkronkan filesystemnya terus-menerus ke branch git, dengan autosave lewat inotifywait dan satu commit yang di amend.
  3. GITHUB_TOKEN bawaan sengaja dibatasi : membuat PR antar-branch atau menerbitkan tanpa secret menuntut PAT khusus atau beralih ke trusted publishing OIDC.

GitHub Actions का इस्तेमाल करने के 5 टेढ़े तरीके (और यह secrets के बारे में क्या सिखाता है)

CI runner मुफ्त VPS में बदला, बॉट जो अपनी pull request खोलता है, बिना किसी secret के npm publish। मेरे repo का दौरा उन GitHub Actions पैटर्न सूचीबद्ध करने के लिए जो साधारण lint + test + deploy से बाहर हैं।

GitHub Actions का इस्तेमाल करने के 5 टेढ़े तरीके

GitHub Actions, कागज पर, क्लासिक CI/CD के लिए बना है : तुम push करते हो, यह lint करता है, परीक्षण करता है, डिप्लॉय करता है। मैंने एक विशेष मामले पर पहले लिखा है (git टैग को email बॉट के लिए डेटाबेस की तरह इस्तेमाल करना, समर्पित लेख देखें)। लेकिन मेरे खुद के repo खँगालने पर, अलग तकनीकों की सूची बनाने लायक पर्याप्त पैटर्न हैं, एक ही प्रोजेक्ट पर कम केंद्रित, तकनीकों की सूची ज्यादा।

पाँच उपयोग, सबसे क्लासिक से सबसे टेढ़े तक।

1. दो run के बीच स्थायी स्थिति के रूप में git टैग

त्वरित अनुस्मारक, पूरा विवरण email-autoreply पर लेख में है। GitHub Actions डिजाइन से stateless है : हर run खाली मशीन से शुरू होता है। वैकल्पिक रास्ता : मान (ID, timestamp, कोई भी छोटा राज्य) समर्पित 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

मुख्य बिंदु : इतिहास कभी जमा न करने के लिए अनाथ शाखा, और repo की शाखा सूची प्रदूषित न करने के लिए शाखा के बजाय बलपूर्वक टैग।

2. पूर्व संकलित build cache के रूप में git टैग

वही विचार परिवार, दूसरा उपयोग : applicative स्थिति संग्रहित करने के बजाय, build कलाकृति संग्रहित की जाती है। build job कोड एक बार संकलित करता है (master पर push पर), फिर dist/ + node_modules/ को runtime टैग में push करता है। cron job, हर निष्पादन पर 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

यह run को लगभग 20s से लगभग 10s में बदलता है। अक्सर चलने वाले cron पर, यह मायने रखता है। actions/cache समान काम करता है (निर्भरताएँ cache करना), लेकिन git टैग ज्यादा सीधा है जब तुम सिर्फ npm install तेज करने के बजाय पूरी तरह संस्करणबद्ध कलाकृति स्थिर करके उसे स्पष्ट इंगित करना चाहते हो।

3. एक ही अनिवार्य check जो कई 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 के बिना, संरक्षित शाखा कॉन्फ़िगर करने के लिए तीन अलग अनिवार्य check टिक करने होंगे, और हर बार job जोड़े या नाम बदले जाने पर यह सूची अद्यतन करनी होगी। test-battery के साथ, repo सेटिंग में टिक करने के लिए एक ही नाम, जो आंतरिक विवरण बदले तब भी स्थिर रहता है।

4. मुफ्त runner को अस्थायी VPS में बदलना

यह सबसे टेढ़ा है, और स्पष्ट रूप से मेरा पसंदीदा : repo-to-vps GitHub Actions runner के नियत उपयोग को पूरी तरह मोड़कर उसे SSH से पहुँचने योग्य Linux मशीन बनाता है, मुफ्त, 6 घंटे तक (एक job की अधिकतम सीमा)।

सिद्धांत : एक job जो tmate (tmux का fork जो SSH या ब्राउजर से साझा करने योग्य terminal सत्र उजागर करता है) चलाने के अलावा लगभग कुछ नहीं करता :

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 का filesystem फेंकने योग्य है : जैसे ही job खत्म होता है, सब गायब हो जाता है। घंटों चलने वाला SSH सत्र बेकार है अगर तुम उसमें जो करते हो वह अगले run पर वाष्पित हो जाता है। समाधान : git शाखा जो filesystem का live snapshot बनती है, लगातार समकालिक।

start-tmate.sh स्क्रिप्ट, क्रम में, करता है :

  1. job शुरू होने पर समर्पित filesystem शाखा से 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   # debounce
  done
}
  1. हर सहेजना नया बनाने के बजाय पिछला कमिट संशोधित करता है (git commit --amend --no-edit), इसलिए filesystem शाखा हमेशा एक ही कमिट पर रहती है, जो हजारों snapshot जमा होने से रोकता है।
  2. while true लूप सत्र मरने पर tmate अपने आप फिर से चलाता है, remain-on-exit on के साथ ताकि exit के बाद भी terminal पहुँच योग्य रहे।
  3. tmate द्वारा उत्पन्न SSH URL host.conf फाइल में लिखा जाता है, filesystem शाखा पर committ किया जाता है, इसलिए job के live log तक कभी पहुँचे बिना GitHub API (gh api .../contents/host.conf) से पुनः प्राप्त किया जा सकता है।
  4. periodic_save दिनचर्या पृष्ठभूमि में हर 5 सेकंड चलती है, अगर inotifywait कोई घटना चूक जाए।

परिणाम : पूरा Linux shell, कहीं से भी पहुँच योग्य, सत्रों के बीच बना रहने वाला filesystem के साथ, जबकि अंतर्निहित बुनियादी ढाँचा (GitHub Actions runner) इसके लिए बिल्कुल डिजाइन नहीं किया गया था। एकमात्र असली सीमा प्रति job 6h timeout है : उसके बाद workflow फिर से चलाना होगा।

5. बॉट जो अपनी pull request खोलता है

konosuba-rpg पर, dev शाखा पर push एक job ट्रिगर करता है जो जाँचता है कि main की ओर पहले से खुली pull request है या नहीं, और वरना 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; // पहले से खुली PR है

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  # 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 फिर भी काम करता है, क्योंकि npm रजिस्ट्री अब OIDC से trusted publishing समर्थन करती है : workflow अपनी पहचान सीधे रजिस्ट्री को साबित करता है (सटीक repo + workflow, npmjs.org तरफ कॉन्फ़िगर), बिना किसी स्थिर token के कहीं पारगमन या संग्रहित हुए। लीक करने को शून्य secret, हर छह महीने घुमाने को शून्य token।


GitHub secrets, गहराई में

ये पाँच पैटर्न सभी, किसी न किसी तरह, secrets प्रश्न छूते हैं। कुछ सिद्धांत जो मेरे सभी workflow में लौटते हैं :

secret जरूरी नहीं साधारण स्ट्रिंग हो। email-autoreply में, ACCOUNTS_JSON बहु खाता config का पूरा minified JSON रखता है : साधारण API कुंजी के बजाय पूरी डेटा संरचना, runtime पर जैसी है वैसी फाइल में डाली गई :

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

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

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

अनुमतियाँ job दर job scope होती हैं, वैश्विक नहीं। यहाँ सूचीबद्ध हर workflow न्यूनतम और टिप्पणित permissions: ब्लॉक घोषित करता है :

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

सार्वजनिक repo पर डिफ़ॉल्ट GITHUB_TOKEN के ऐतिहासिक रूप से काफी व्यापक अधिकार हैं ; उसे स्पष्ट रूप से उतने तक सीमित करना जितना job को सच में चाहिए, नुकसान सीमित करता है अगर शृंखला में कोई तृतीय पक्ष क्रिया (uses: कोई-एक/action@संस्करण) समझौता की हुई निकले।

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


3 मुख्य बिंदु

  1. git टैग (अनाथ, force-push किया हुआ) न्यूनतम डेटाबेस या पूर्व संकलित build cache बन सकता है : एक ही तंत्र के दो अलग उपयोग।
  2. मुफ्त GitHub Actions runner स्थायी SSH shell बन सकता है अगर उसके filesystem को git शाखा की ओर लगातार समकालिक करना स्वीकार किया जाए, inotifywait से autosave और एक ही संशोधित कमिट के साथ।
  3. डिफ़ॉल्ट GITHUB_TOKEN जानबूझकर सीमित है : अंतर शाखा PR बनाना या बिना secret प्रकाशित करने के लिए या तो समर्पित PAT चाहिए, या OIDC trusted publishing की ओर जाना।

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

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

5 طرق ملتوية لاستخدام GitHub Actions

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

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

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

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

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

# كتابة الحالة (فرع يتيم، commit واحد، دفع إجباري للوسم)
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

وهذا يغيّر التشغيل من ~20s إلى ~10s. في 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. تحويل runner مجاني إلى VPS مؤقت

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

المبدأ : وظيفة لا تفعل شيئا تقريبا سوى إطلاق 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

المعضلة الحقيقية أن نظام ملفات runner في 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   # debounce
  done
}
  1. كل حفظ يعدّل الـcommit السابق بدلا من إنشاء جديد (git commit --amend --no-edit)، فيبقى فرع filesystem دائما على commit واحد، مما يتجنب تراكم آلاف اللقطات.
  2. حلقة while true تعيد إطلاق tmate تلقائيا إذا ماتت الجلسة، مع remain-on-exit on ليبقى الطرفية قابلا للوصول حتى بعد exit.
  3. يُكتَب رابط SSH الذي يولده tmate في ملف host.conf، مُدخَل على فرع filesystem، فيُسترجَع عبر GitHub API (gh api .../contents/host.conf) دون الوصول أبدا لسجلات الوظيفة مباشرة.
  4. روتين periodic_save يعمل كل 5 ثوانٍ في الخلفية، تحسبا لفوات inotifywait حدثا ما.

النتيجة : shell لينكس كامل، قابل للوصول من أي مكان، مع نظام ملفات يدوم بين الجلسات، بينما البنية الكامنة (runner في GitHub Actions) لم تُصمَّم لذلك إطلاقا. الحد الحقيقي الوحيد هو مهلة 6h لكل وظيفة : بعدها يجب إعادة إطلاق الـworkflow.

5. بوت يفتح pull requests الخاصة به

في konosuba-rpg، دفع على فرع dev يحفّز وظيفة تتحقق إن كانت توجد pull request مفتوحة فعلا نحو 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; // توجد PR مفتوحة فعلا

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

التفصيل المهم هنا هو الرمز المستخدم. لا يستخدم هذا الـ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  # 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 مع ذلك، لأن سجل npm يدعم الآن النشر الموثوق عبر OIDC : يثبت الـworkflow هويته مباشرة للسجل (المستودع + الـworkflow الدقيقان، المُعدَّلان جهة npmjs.org)، دون عبور أي رمز ثابت أو تخزينه في أي مكان. صفر secret قابل للتسريب، وصفر رمز يُدوَّر كل ستة أشهر.


أسرار GitHub، في العمق

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

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

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

وهذا يتجنب الاضطرار لإدخال ملف إعداد في الـcommit، ولو مشفرا، ويُحدَّث بنقرة في إعدادات المستودع دون لمس الكود.

لرمز 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
  # Only allow writing checks, everything else is read-only

كان لرمز GITHUB_TOKEN الافتراضي تاريخيا حقوق واسعة على مستودع عام ؛ وتقييده صراحة لما تحتاجه الوظيفة فعلا يحد من الأضرار إذا تبين أن action طرف ثالث في السلسلة (uses: quelqu-un/action@version) مُخترَقة.

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


3 نقاط أساسية

  1. وسم git (يتيم، مُدفَع بالإجبار) يمكنه العمل كقاعدة بيانات مصغرة أو ككاش بناء مُجمَّع مسبقا : استخدامان متميزان لنفس الآلية.
  2. يمكن لـrunner مجاني في GitHub Actions أن يصبح shell SSH دائما إذا قبلنا مزامنة نظام ملفاته باستمرار نحو فرع git، مع حفظ تلقائي عبر inotifywait وcommit واحد مُعدَّل.
  3. رمز GITHUB_TOKEN الافتراضي محدود عمدا : إنشاء PR بين الفروع أو النشر دون secret يتطلب إما PAT مخصصا، أو انتقالا إلى النشر الموثوق OIDC.

5 cách lắt léo dùng GitHub Actions (và điều nó dạy về secret)

Một runner CI biến thành VPS miễn phí, một bot tự mở pull request cho chính nó, publish npm không cần secret nào. Dạo qua các repo của tôi để liệt kê các pattern GitHub Actions vượt khỏi kiểu \"lint + test + deploy\" đơn thuần.

5 cách lắt léo dùng GitHub Actions

GitHub Actions, trên giấy tờ, sinh ra cho CI/CD cổ điển : bạn đẩy, nó lint, nó test, nó deploy. Tôi từng viết về một trường hợp riêng (dùng tag git làm cơ sở dữ liệu cho bot email, xem bài chuyên đề). Nhưng khi lục trong chính các repo của mình, có đủ pattern khác nhau để đáng một bài riêng, ít tập trung vào một dự án, thiên về danh mục kỹ thuật hơn.

Năm cách dùng, từ cổ điển nhất tới quái nhất.

1. Tag git làm trạng thái bền vững giữa hai lần chạy

Nhắc nhanh, chi tiết đầy đủ nằm trong bài về email-autoreply. GitHub Actions stateless theo thiết kế : mỗi lần chạy khởi đầ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) trong một tag git chuyên dụng, không bao giờ trong nhánh.

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

# ghi trạng thái (nhánh mồ côi, một commit duy nhất, force-push tag)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

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

2. Tag git làm cache build tiền biên dịch

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

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

Việc này đổi lần chạy từ ~20s xuống ~10s. Trên cron chạy thường xuyên, điều đó đáng kể. actions/cache làm việc tương tự (cache phụ thuộc), nhưng tag git trực tiếp hơn khi bạn muốn thẳng tay đóng băng một artefact có phiên bản và trỏ rõ vào nó, thay vì chỉ tăng tốc một npm install.

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

Pattern nhỏ trông chẳng có gì nhưng thay đổi cuộc sống trong cấu hình nhánh 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ì khác ngoài phụ thuộc 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 nhánh bảo vệ sẽ đòi tích ba check bắt buộc riêng, và cập nhật danh sách đó mỗi khi job được thêm hay đổi tên. Với test-battery, một tên duy nhất để tích trong cài đặt repo, ổn định dù chi tiết bên trong thay đổi.

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

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

Nguyên tắc : một job hầu như không làm gì khác ngoài chạy tmate (một fork của tmux phơi bày phiên terminal chia sẻ được qua SSH hay trình duyệt) :

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

Bài toán hóc búa thật sự là filesystem của runner GitHub Actions dùng một lần : hễ job kết thúc, mọi thứ biến mất. Một phiên SSH kéo dài hàng giờ chẳng để làm gì nếu mọi thứ bạn làm trong đó bốc hơi ở lần chạy tiếp. Giải pháp : một nhánh git đóng vai snapshot trực tiếp của filesystem, đồng bộ liên tục.

Script start-tmate.sh làm, theo thứ tự :

  1. Khôi phục filesystem từ nhánh chuyên dụng filesystem lúc job khởi động (git reset --hard lên nó).
  2. Theo dõi thay đổi tệp bằng inotifywait liên tục, và commit + đẩy ngay hễ tệp nào động đậy :
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. Mỗi lần lưu sửa commit trước thay vì tạo commit mới (git commit --amend --no-edit), nên nhánh filesystem luôn ở một commit duy nhất, tránh tích hàng nghìn snapshot.
  2. Vòng lặp while true chạy lại tmate tự động nếu phiên chết, với remain-on-exit on để terminal vẫn tiếp cận được ngay cả sau exit.
  3. URL SSH do tmate tạo được ghi vào tệp host.conf, commit lên nhánh filesystem, nên lấy được qua API GitHub (gh api .../contents/host.conf) mà không bao giờ cần truy cập log job trực tiếp.
  4. Routine periodic_save chạy mỗi 5 giây trong nền, phòng khi inotifywait hụt sự kiện.

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

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

Trên konosuba-rpg, một lần đẩy lên nhánh dev kích hoạt job kiểm tra đã có pull request mở nào tới main chưa, và tự tạo một cái nếu chưa, qua actions/github-script và API REST GitHub :

const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return; // không có gì để đề xuất

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

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

Chi tiết đáng kể ở đây là token dùng. Workflow này không dùng GITHUB_TOKEN tự động : nó đòi một 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. Đăng lên npm không cần secret nào

Kín đáo nhất trong năm, 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 secret npm nào. Không NPM_TOKEN, không NODE_AUTH_TOKEN. Chỉ có vậy :

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 vẫn chạy, vì registry npm giờ hỗ trợ trusted publishing qua OIDC : workflow chứng minh danh tính trực tiếp với registry (đúng repo + đúng workflow, cấu hình phía npmjs.org), mà không token tĩnh nào đi qua hay lưu ở đâu. Không secret để rò rỉ, không token để xoay vòng mỗi sáu tháng.


Secret GitHub, chuyên sâu

Năm pattern này đều chạm, cách này hay cách khác, tới câu hỏi secret. Vài nguyên tắc lặp lại khắp 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 thu gọn của cấu hình đa tài khoản : một cấu trúc dữ liệu đầy đủ thay vì khóa API đơn thuần, bơm nguyên vào tệp lúc runtime :

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

Việc này tránh phải commit tệp cấu hình, dù mã hóa, và cập nhật trong một cú bấm ở cài đặt repo mà không đụng tới mã.

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

Quyền scope theo từng job, không phải 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ó comment :

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

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õ nó ở đúng 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 (uses: ai-đó/action@phiên-bản) hóa ra 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 thiện nhất của ý này : thay vì quản lý xoay vòng, hết hạn và nguy cơ rò rỉ của NPM_TOKEN, workflow chứng minh danh tính bằng mật mã (đúng repo này, đúng workflow này) trực tiếp với dịch vụ bên thứ ba. Logic tương tự có 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. Tag git (mồ côi, force-push) có thể đóng vai cơ sở dữ liệu tối giản hay cache build tiền biên dịch : hai cách dùng riêng của cùng một cơ chế.
  2. Runner GitHub Actions miễn phí có thể thành shell SSH bền vững nếu chấp nhận đồng bộ filesystem liên tục lên nhánh git, với autosave bằng inotifywait và một commit duy nhất được sửa.
  3. GITHUB_TOKEN mặc định bị giới hạn có chủ ý : tạo PR liên nhánh hay đăng không cần secret đòi hoặc PAT chuyên dụng, hoặc chuyển sang trusted publishing OIDC.

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