add-oidc-client

Use when the user wants an app to authenticate via OpenID Connect (OAuth2 Authorization Code flow) against the shared Authelia identity provider — triggers on phrases like "add OIDC to X", "add oidc login to X", "wire up SSO for X", "make X log in with Authelia", "register X as an OIDC client", "add openid connect auth", "give X SSO".

npx skills add git@git.burnttech.com:infra/ai-plugin.git add-oidc-client
Triggers:
add OIDC to X add oidc login to X wire up SSO for X make X log in with Authelia register X as an OIDC client add openid connect auth give X SSO
Full documentation

add-oidc-client

Register an application as an OpenID Connect relying party against the shared Authelia identity provider, and wire the app's own OIDC client config to match.

Which integration does the app need?

Two different ways an app can defer auth to Authelia — pick one before starting:

  • Forward-auth (transparent, no app code) — the app has no login screen of its own; Traefik intercepts every request and Authelia handles auth/2FA in front of it, then forwards Remote-* headers. This is add-traefik-deploy §4.7 — not this skill.
  • Native OIDC (this skill) — the app has its own "log in with SSO / OIDC / OAuth2" support (a client_id, client_secret, discovery URL, redirect URI to configure) and performs the Authorization Code flow itself. NetBird (~/git/netbird, self-hosted zero-trust VPN) is the canonical example, registered in ~/git/authelia.

If unsure which the app supports, check the app's own docs/config for an "OIDC provider" or "OAuth2 / OpenID Connect" section — that's native OIDC. An app with only a reverse-proxy auth header integration (or no auth at all) wants forward-auth instead.

Reference: ~/git/authelia

~/git/authelia's own README has a full "Adding an OIDC Client" walkthrough (steps, client template, endpoint table, scope/claims mapping) — read it before editing config; this skill summarizes and sequences it end-to-end across both the Authelia side and the consuming app's side. config/configuration.yml's identity_providers.oidc block is the live example (NetBird's client entry).

Key facts from that repo, load-bearing for this skill:

  • identity_providers.oidc lives only in the base config/configuration.yml — config/configuration.prod.yml does not override it. Registering a client makes it available against both auth.dev.burnttech.com and auth.burnttech.com from the same entry — put every environment's redirect URI the app needs in one redirect_uris list, don't create separate dev/prod client entries unless the app truly needs different secrets or policies per environment.
  • Authelia refuses to start with an empty clients: list — never remove the last client without replacing it.
  • The client's client_secret in config is a PBKDF2-SHA512 hash, never the plaintext. The plaintext is handed to the consuming app out-of-band (its own GitLab CI/CD masked variable) and is never committed anywhere.

1. Generate and hash the client secret

authelia crypto rand --length 72 --charset rfc3986
# then, using that value as <secret>:
authelia crypto hash generate pbkdf2 --variant sha512 --password '<secret>'

Keep the plaintext <secret> aside for step 4 — it is never written to the authelia repo.

2. Register the client in ~/git/authelia

Add an entry under identity_providers.oidc.clients in config/configuration.yml, following the existing NetBird entry as the template:

identity_providers:
  oidc:
    clients:
      - client_id: '<unique id, e.g. a generated slug — see authelia crypto rand>'
        client_name: '<human-readable name shown on the consent screen>'
        client_secret: '<hash from step 1>'
        public: false          # true + require_pkce/pkce_challenge_method for a
                                # browser SPA that can't hold a secret (see NetBird)
        authorization_policy: 'two_factor'
        redirect_uris:
          - 'https://<app-host>/<app-specific-callback-path>'
        scopes:
          - openid
          - profile
          - email
          - groups
        grant_types:
          - authorization_code
        response_types:
          - code

Decisions to make per app, each with an existing precedent in config/configuration.yml:

  • public vs confidential — public: true with require_pkce: true and pkce_challenge_method: 'S256' only for a browser SPA that cannot keep a secret (embedding one in shipped JS isn't a secret). Anything with a server-side backend that can hold the client secret is confidential (public: false, no PKCE requirement).
  • redirect_uris — must match the app's own callback path(s) exactly (protocol, host, path, trailing slash). Get the exact value from the app's OIDC settings/docs, not a guess — a mismatch fails the exchange with invalid_redirect_uri.
  • scopes — always include openid; add profile/email/groups only if the app actually consumes those claims. offline_access only if the app needs refresh tokens.
  • audience — only needed if the app sends an audience/resource parameter on the authorization request (some SPAs echo their own client_id here, see NetBird's comment in configuration.yml); omitting it when the app needs it fails with invalid_target.
  • Custom claims — if the app needs claims beyond the scope defaults, add a claims_policies entry (see the netbird policy in configuration.yml) and reference it via claims_policy: on the client.

Update the client table and add a short entry in ~/git/authelia/README.md's "Registered clients" section (same repo, same commit) so the registration is discoverable without spelunking the YAML.

3. Store and hand off the secret

In the app's own repo (not authelia), add the plaintext secret from step 1 as a masked + protected GitLab CI/CD variable (Settings > CI/CD > Variables), scoped to whichever environment(s) it deploys to — this is the same pattern authelia's own secrets use (see generate-secrets.sh and .gitlab-ci.yml in ~/git/authelia). Never commit the plaintext secret to either repo.

4. Configure the consuming app

Point the app's own OIDC/SSO settings at Authelia:

Setting Value
Discovery / issuer URL https://auth.dev.burnttech.com (dev) or https://auth.burnttech.com (prod) — /.well-known/openid-configuration is served there
Authorization endpoint /api/oidc/authorization
Token endpoint /api/oidc/token
Userinfo endpoint /api/oidc/userinfo
client_id the id chosen in step 2
client_secret the plaintext from step 1, injected via the CI/CD variable from step 3 — never hardcoded
Redirect/callback URI must exactly match redirect_uris in step 2
Scopes must be a subset of the client's configured scopes

Most apps take these as env vars or a config file read at startup — wire them the same way the app already sources its other secrets/config (env file, CI-injected env var, mounted secret file), matching whatever pattern that repo already uses.

5. Deploy and verify

  1. Commit and deploy the authelia config change through its own CI (dev auto-deploys on merge to default branch; prod follows after build_prod/deploy_prod — see ~/git/authelia/.gitlab-ci.yml).
  2. Confirm the new client is live: curl https://auth.dev.burnttech.com/.well-known/openid-configuration (no auth required — same endpoint the Authelia smoke test hits).
  3. Deploy the consuming app with its OIDC settings from step 4.
  4. Drive an actual login through the app's "log in with SSO" flow end to end (browser, not just curl) and confirm it lands back in the app authenticated. A redirect_uri/invalid_client/invalid_target mismatch only shows up at this step, not at config-validation time.

6. Report back

After wiring the client, report:

  • the client_id registered and which repo/file it lives in (~/git/authelia's config/configuration.yml, plus configuration.prod.yml if a prod-only override was added),
  • which CI/CD variable in the app's repo now holds the plaintext secret,
  • the exact redirect URI(s) configured,
  • whether the end-to-end login flow was verified in a browser, and against which environment(s).

add-traefik-deploy

Use when the user wants to put an app behind the shared Traefik reverse proxy and/or wire up GitLab CI to build, publish, and deploy it to dev and/or production — triggers on phrases like "add traefik to X", "put X behind traefik", "deploy X to dev", "deploy X to prod", "set up CI for X", "add a dev deploy pipeline", "add a production deploy pipeline", "publish to the gitlab registry", "pull from the registry for dev deploys", "put X behind 2FA", or "add authelia to X".

npx skills add git@git.burnttech.com:infra/ai-plugin.git add-traefik-deploy
Triggers:
add traefik to X put X behind traefik deploy X to dev deploy X to prod set up CI for X add a dev deploy pipeline add a production deploy pipeline publish to the gitlab registry pull from the registry for dev deploys put X behind 2FA add authelia to X
Full documentation

add-traefik-deploy

Wire an application into the shared Traefik reverse proxy (dev and, per §4, production) and stand up the GitLab CI pipeline that builds, publishes to the GitLab Container Registry, and deploys by pulling from that registry.

Reference layout (already running — do not recreate)

This skill assumes a shared Traefik stack already runs on the dev host and owns ports 80/8080. The canonical source for it is the traefik repo (canonical clone path ~/git/traefik):

traefik/
  compose.yaml       # the whole stack: service `traefik`, ports 80/443,
                     # docker.sock mount, and ALL Traefik config as `command:`
                     # flags — there is no traefik.yml / static config file
  compose.prod.yaml  # prod-only overlay: TLS (real ACME), HTTP->HTTPS
                     # redirect, exposedByDefault=false — layered via
                     # COMPOSE_FILE=compose.yaml:compose.prod.yaml
  .gitlab-ci.yml     # validate on `ci`; deploy_traefik on `dev-env`
                     # (automatic); deploy_traefik_prod on `proxmoxdocker3`
                     # (manual, needs deploy_traefik first)
  README.md

The same host pattern runs both environments: dev's stack is compose.yaml alone with DOMAIN=dev.burnttech.com; prod layers compose.prod.yaml on top with DOMAIN=burnttech.com — no separate "prod" subdomain, production apps live directly on the bare domain. ~/git/authelia is the first real app deployed this way end-to-end (dev at auth.dev.burnttech.com, prod at auth.burnttech.com) and is the canonical reference for §4.

Traefik is configured entirely through CLI flags in compose.yaml's command: list. The ones this skill depends on:

command:
  - "--providers.docker.exposedByDefault=true"
  - "--providers.docker.network=traefik"
  - "--providers.docker.defaultRule=Host(`{{ normalize .Name }}.${DOMAIN:-dev.burnttech.com}`)"
  - "--entrypoints.web.address=:80"
  - "--entrypoints.websecure.address=:443"

Read ~/git/traefik/compose.yaml before changing anything here — if you are looking for a traefik.yml, there isn't one, and inventing one would shadow nothing and take effect never.

That stack publishes the shared Docker network traefik on the host. Every app this skill touches just joins that external network — it does not run its own Traefik, publish 80/443, or mount the Docker socket. Default hostname for a joined container is <container-name>.dev.burnttech.com — from the provider's defaultRule above, where ${DOMAIN} defaults to dev.burnttech.com unless the stack's environment overrides it. Override per-service with a Traefik router Host(...) label.

Because exposedByDefault=true is set, a container on the traefik network is routed without any traefik.enable=true label. This skill still sets that label explicitly: it is harmless, it documents intent, and it keeps the compose file correct if the provider is ever flipped to exposedByDefault=false.

For an app that also needs a production deploy path and is going behind the shared Traefik proxy, §4 below is the fully prescriptive prod delta — don't reach for setup-docker-pipeline for that case, it would duplicate and drift from this. Use setup-docker-pipeline only for apps headed to production that are not going behind shared Traefik (not user-facing over HTTP, or fronted some other way). This skill remains authoritative for everything Traefik, dev and prod alike.

Canonical example

~/git/mad-jars-web is the cleanest end-to-end reference — a single-service Rust app with a Dockerfile, a docker-compose.yml that joins traefik, and a .gitlab-ci.yml doing exactly test → build/push → pull+deploy. Mirror its shape; swap the test-stage image and cargo test/pnpm test for the app's language. ~/git/friendshub (Node, multi-service, env-file secrets) and ~/git/stocker (multi-service monorepo with build matrix) are heavier variations on the same pattern — consult them only when the app genuinely needs more than one container.

What the app repo needs

Three files, all adapted from mad-jars-web:

  1. Dockerfile — multi-stage build → slim runtime, non-root user, a real HEALTHCHECK-able port.
  2. docker-compose.yml — service joins the external traefik network, sets Traefik labels, and reads image: from $CI_REGISTRY_IMAGE / $IMAGE_TAG so CI can pull the published image instead of rebuilding.
  3. .gitlab-ci.yml — test on ci, build+publish on dev-env, pull+deploy on dev-env; default-branch-only for build/deploy.

Read all three reference files before writing the app's versions — copy the structure, change only what the language/app demands.


1. Make the app Traefik-aware

Healthcheck + non-root runtime (Dockerfile)

The image must expose one HTTP port and respond to a health probe on it, and the container must not run as root. From mad-jars-web/Dockerfile (Rust; adapt the build stage for other languages):

FROM rust:1.95-bookworm AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release --locked

FROM debian:bookworm-slim
RUN apt-get update \
 && apt-get install --no-install-recommends --yes curl \
 && rm -rf /var/lib/apt/lists/* \
 && useradd --system --create-home --uid 10001 appuser
COPY --from=builder /app/target/release/the-app /usr/local/bin/the-app
EXPOSE 3000
ENV BIND_ADDR=0.0.0.0:3000
USER appuser
# optional entrypoint that fixes named-volume ownership before dropping privs:
# COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint
# ENTRYPOINT ["docker-entrypoint"]
CMD ["the-app"]

For Node, see friendshub/docker/Dockerfile (production target, single image serving app and worker via different command:). Whatever the language, make sure: one EXPOSEd port, a /health (or equivalent) endpoint returning 200, and a non-root USER.

Join the traefik external network + labels (docker-compose.yml)

services:
  the-app:
    # Local `docker compose up --build` rebuilds; CI's deploy sets these vars
    # so `pull` + `up -d` use the published registry image instead.
    image: "${CI_REGISTRY_IMAGE:?Set CI_REGISTRY_IMAGE}:${IMAGE_TAG:?Set IMAGE_TAG}"
    restart: unless-stopped
    networks:
      - traefik
    labels:
      - traefik.enable=true
      # Optional — without it, Traefik's defaultRule already serves
      # <container-name>.dev.burnttech.com. Add it only to override the host.
      - traefik.http.routers.the-app.rule=Host(`the-app.dev.burnttech.com`)
      - traefik.http.routers.the-app.entrypoints=web
      - traefik.http.services.the-app.loadbalancer.server.port=3000
    environment:
      BIND_ADDR: 0.0.0.0:3000
    healthcheck:
      test: ["CMD-SHELL", "curl --fail --silent http://127.0.0.1:3000/health || exit 1"]
      interval: 10s
      timeout: 3s
      retries: 6
      start_period: 5s
    # named volumes start root-owned; if the app writes to one, either chown in
    # an entrypoint (see mad-jars-web/docker-entrypoint.sh) or run a one-shot
    # init. Keep stateful volumes out of the traefik network.
    volumes:
      - the-app-data:/data

volumes:
  the-app-data:

networks:
  traefik:
    external: true

Rules:

  • networks.traefik.external: true — the network already exists on the host (the shared Traefik stack created it). Never declare it as a local bridge here; a duplicate name will either fail or shadow the wrong network.
  • traefik.enable=true + entrypoints=web only. Do not wire a websecure/443 router or TLS labels in dev. The shared stack does define a websecure entrypoint on :443 and publishes the port, but it has no certificate resolver configured — so a websecure router would match and then fail to serve a usable certificate. TLS is a prod concern; the stub in §4 covers adding the resolver.
  • loadbalancer.server.port is only mandatory when the container exposes multiple ports or Traefik can't infer it. One-port apps can omit it.
  • Apps that need a private sidecar (postgres, minio, …) declare a second internal bridge network alongside traefik and join both; see friendshub/docker/docker-compose.yml (internal: { driver: bridge } + traefik: { external: true }). Never expose the database port to the host in anything that goes to prod — 127.0.0.1:5432:5432 dev-convenience binds get a # remove in production comment.

If secrets are needed, mirror friendshub's pattern: an ENV_FILE GitLab File-type CI/CD variable written to a chmod 700 directory on the runner host and loaded via docker compose --env-file. Do not commit .env files to the repo.


2. GitLab CI — test, build/publish, deploy-to-dev

Runner tags on this GitLab instance are fixed: ci = clean Docker-executor runner (no host Docker, good for lint/test); dev-env = shell executor on the dev host with a working Docker install and a checkout of the shared Traefik stack. Build + deploy must run on dev-env; tests run on ci. Never tag a build/push job ci — that runner's daemon isn't configured for the GitLab registry (confirmed in friendshub/.gitlab-ci.yml's comments).

Full pipeline (single service)

Adapt from mad-jars-web/.gitlab-ci.yml. Three stages, default-branch gating on build+deploy:

stages:
  - test
  - build
  - deploy

variables:
  IMAGE_TAG: "$CI_COMMIT_SHA"
  DOCKER_BUILDKIT: "1"

# ---- test stage: language-specific, runs on `ci` -------------------------
# See "Test-stage snippets" below; pick the one matching the app.

test:
  stage: test
  # image: <language image>
  tags:
    - ci
  script:
    - <run tests>

# ---- build: docker build + push to GitLab registry, on dev-env -----------
build_image:
  stage: build
  tags:
    - dev-env
  needs:
    - test
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" --username "$CI_REGISTRY_USER" --password-stdin
    - docker build --pull --tag "$CI_REGISTRY_IMAGE:$IMAGE_TAG" --tag "$CI_REGISTRY_IMAGE:dev" .
    - docker push "$CI_REGISTRY_IMAGE:$IMAGE_TAG"
    - docker push "$CI_REGISTRY_IMAGE:dev"
    - docker logout "$CI_REGISTRY"

# ---- deploy: pull published image, up -d, healthcheck, on dev-env --------
deploy_dev:
  stage: deploy
  tags:
    - dev-env
  needs:
    - build_image
  resource_group: dev-environment
  environment:
    name: development
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" --username "$CI_REGISTRY_USER" --password-stdin
    - export IMAGE_TAG="$CI_COMMIT_SHA"
    - docker compose pull
    - docker compose up -d --remove-orphans --wait --wait-timeout 60
    - docker compose exec -T the-app curl --fail --show-error --silent http://127.0.0.1:3000/health
    - docker logout "$CI_REGISTRY"

Contract this skill enforces:

  • Build publishes; deploy pulls. Never rebuild on deploy. The deploy job docker logins, docker compose pulls (using the image published in the build stage), then up -d. The :-?-required image: interpolation in docker-compose.yml is what makes pull fetch the registry image rather than build.
  • build_image pushes two tags — $IMAGE_TAG (= $CI_COMMIT_SHA, immutable) and :dev (rolling latest on the default branch). Deploy pins to the SHA tag for reproducibility (export IMAGE_TAG="$CI_COMMIT_SHA" in the deploy script); :dev is a convenience for humans docker pulling by hand.
  • Default-branch gating. build_image and deploy_dev carry rules: if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH. MR pipelines run only the test stage — they validate, they must not push or touch the dev host.
  • resource_group: dev-environment (or <project>-dev) on the deploy job serializes deploys so two default-branch pushes can't up -d the same stack concurrently.
  • Health check after up. deploy_dev ends with docker compose exec ... curl /health (or the app's equivalent). A deploy that starts containers but leaves them unhealthy fails the pipeline — don't relax this.
  • Logout in after_script is a nice-to-have; mad-jars-web does it inline at the end of the script, friendshub uses after_script. Either, not neither.

Test-stage snippets (pick one)

Rust (mad-jars-web) — plain Docker executor, no extra services:

test:
  stage: test
  image: rust:1.95-bookworm
  tags:
    - ci
  script:
    - cargo test --locked

Node (SvelteKit/Express + Postgres) (friendshub) — Glab services: attached Postgres, runs on the clean Docker executor without Docker CLI/DinD:

test:
  stage: test
  image: node:22-alpine
  services:
    - name: postgres:17-alpine
      alias: postgres
  variables:
    POSTGRES_USER: app_ci
    POSTGRES_PASSWORD: app_ci
    POSTGRES_DB: app_ci
    DATABASE_URL: postgresql://app_ci:app_ci@postgres:5432/app_ci
  script:
    - |
      for i in $(seq 1 20); do
        nc -z postgres 5432 && break
        echo "  waiting for postgres ($i/20)..."; sleep 2
      done
    - corepack enable pnpm
    - pnpm install --frozen-lockfile
    - pnpm run db:migrate
    - pnpm run check
    - pnpm test
  tags:
    - ci

Generic "container with /health" — for apps whose tests are themselves dockerized, or where there's no meaningful unit-test stage yet:

test:
  stage: test
  image: docker:29.6.2-cli
  tags:
    - ci
  script:
    - docker --version
    - docker compose -f docker-compose.yml config --quiet

(The docker-cli image on a clean Docker executor can validate the compose file without a running daemon — same trick traefik/.gitlab-ci.yml's validate_compose uses. Real build/push still happens on dev-env.)

Multi-service variations

  • Monorepo / build matrix: per-service build jobs sharing a hidden .build-docker-template: &build-docker-template, each with variables: SERVICE_NAME: …, the deploy job needs:-ing all of them and pulling by $CI_COMMIT_SHORT_SHA — see stocker/.gitlab-ci.yml. Keep the publish→pull contract: build jobs push, deploy job pulls, nothing rebuilds.
  • App + worker sharing one image: one build job, two compose services with the same image:/build: and different command: — see friendshub.
  • Ad-hoc re-deploy of an older branch: a when: manual deploy job keyed off $CI_COMMIT_REF_SLUG-tagged images — optional, see stocker's deploy:dev:adhoc. Not required for the basic dev path.

3. Verify on a branch before merging

The dev deploy only runs on the default branch, so verify on a feature branch first:

  1. On the branch, the test job runs (it's not default-branch-gated). Get it green.
  2. docker compose config --quiet locally to catch compose errors CI would. The image: line uses ${VAR:?} required interpolation, so export both first or config aborts on the unset variable — that abort is the feature working, not a compose error: CI_REGISTRY_IMAGE=the-app IMAGE_TAG=dev docker compose config --quiet
  3. Optionally build the image locally and docker compose up against the dev host's traefik network (if you're on that host) to confirm labels route — curl -H 'Host: the-app.dev.burnttech.com' http://<dev-host>/health should hit the container.

Only merge to the default branch once test is green. The merge triggers build_image → deploy_dev; watch that downstream pipeline (use the mr-pipeline-check / execute-ticket skills' polling pattern — never a single-call while loop) and confirm deploy_dev's healthcheck line passes.

If deploy_dev fails at the up -d or healthcheck step, common causes, in order of likelihood:

  • docker compose pull failed/the image wasn't pushed — check build_image actually ran (default-branch rule) and the registry credentials resolve (echo "$CI_REGISTRY_USER" non-empty on the runner).
  • The traefik external network doesn't exist on the host — start the shared Traefik stack first (docker compose up -d in ~/git/traefik).
  • Wrong loadbalancer.server.port — the port the app listens on inside the container must match. Container logs (docker compose logs the-app) tell you fast.
  • Health endpoint path differs from /health — align the compose healthcheck.test and the CI exec ... curl line with the app's real path.

4. Production

Dev is §1–3 above. Production is the delta on top of it — same app image, same Traefik network concept, different host, domain, and TLS posture. Canonical reference: ~/git/authelia (docker-compose.prod.yml + .gitlab-ci.yml's deploy_prod), the first app wired this way end-to-end; ~/git/traefik (compose.prod.yaml + deploy_traefik_prod) for the proxy side of the same delta.

4.1 Domain

Prod apps live on the bare domain, not a prod. subdomain: <container-name>.burnttech.com, from the prod stack's DOMAIN=burnttech.com (vs. dev's DOMAIN=dev.burnttech.com) feeding the same defaultRule=Host({{ normalize .Name }}.${DOMAIN}). Confirmed by both traefik's own dashboard (traefik.burnttech.com) and authelia (auth.burnttech.com). Never write *.prod.burnttech.com — that domain doesn't exist and isn't the convention.

4.2 docker-compose.prod.yml — TLS via the shared resolver

traefik/compose.prod.yaml puts --entrypoints.websecure.http.tls.certresolver=le on the entrypoint itself, so every router on websecure gets a real certificate automatically — apps add no certresolver/TLS labels of their own. The app's prod override only needs to move its router onto that entrypoint (labels merge by key, so only the changed ones need restating):

# docker-compose.prod.yml — deploy with:
#   docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
services:
  the-app:
    labels:
      - traefik.http.routers.the-app.rule=Host(`the-app.burnttech.com`)
      - traefik.http.routers.the-app.entrypoints=websecure

If the base docker-compose.yml has no explicit Host(...) rule (relying on defaultRule), the prod override doesn't need the rule= line either — only entrypoints=websecure changes. Dev's docker-compose.yml must still not reference websecure on its own (§1) since only the prod overlay's Traefik instance has a resolver configured for it.

4.3 Runner tag

Production deploy jobs run on proxmoxdocker3 — the real tag for the shell executor on the prod host (confirmed in both traefik and authelia .gitlab-ci.yml), not a placeholder prod-env. Never tag a prod deploy job dev-env.

4.4 CI — stage, gating, image

Reuse the same SHA-tagged image the dev build stage already pushed — do not add a separate prod build job or a :latest tag unless the app's own image needs to differ between environments (the one real exception is authelia's Dockerfile.prod, needed only because its prod runner is a Docker executor talking to the host daemon over a socket, so a bind-mounted ./config would resolve against the host filesystem instead of the job's checkout — that doesn't apply to apps like mad-jars-web that already build a self-contained image).

deploy_prod:
  stage: deploy
  tags:
    - proxmoxdocker3
  needs:
    - deploy_dev            # prod follows dev, same pipeline
  resource_group: prod-environment
  environment:
    name: production
    url: https://the-app.burnttech.com
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
      when: manual
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" --username "$CI_REGISTRY_USER" --password-stdin
    - export IMAGE_TAG="$CI_COMMIT_SHA"
    - docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
    - docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60
    - docker compose -f docker-compose.yml -f docker-compose.prod.yml exec -T the-app curl --fail --show-error --silent https://127.0.0.1:3000/health
    - docker logout "$CI_REGISTRY"
  • Gating: when: manual is the default — a human decides when an app goes to prod (mirrors traefik/deploy_traefik_prod, which additionally sets allow_failure: true so the pipeline stays green while the manual job waits). authelia/deploy_prod is the one deliberate exception: it runs automatically right after deploy_dev because there's no integration-test stage yet to gate on — treat manual as the default and only drop it with an explicit reason in a comment, the way authelia's CI does.
  • needs: [deploy_dev] — prod follows dev in the same pipeline so a bad config is always caught on the dev host first (traefik's comment: "Prod follows dev, so a bad ingress config is caught on the dev host first").
  • resource_group: prod-environment (or <project>-prod) — same serialization reasoning as dev's resource_group, on the prod host instead.

4.5 Secrets

Same ENV_FILE/CI-variable pattern as dev, but scoped to the production GitLab environment with its own protected + masked values — never reuse a dev secret in prod. authelia's four AUTHELIA_* variables (LDAP password, JWT secret, session secret, storage encryption key), each set with environment scope development / production, is the pattern to copy: same variable names, environment-scoped to different values.

4.6 No host-published ports

Strip every 127.0.0.1:<port>:<port> dev-convenience bind (DB, admin UIs, etc.) from the prod compose override — those exist for local debugging on the dev host only.

4.7 Optional: gate the app behind Authelia (2FA / SSO)

If the app needs authentication in front of it rather than handling its own login, put it behind the already-running ~/git/authelia deployment instead of building auth into the app. This skill does not stand Authelia up (that's authelia's own repo); it just wires an app to consume it. Add one middleware label to the app's prod router (Authelia's own compose declares the authelia middleware on the shared traefik network, so any router on that network can reference it):

services:
  the-app:
    labels:
      - traefik.http.routers.the-app.middlewares=authelia@docker

That's it on the app side — Authelia's forward-auth intercepts the request, handles LDAP login + 2FA (or SSO cookie), and forwards Remote-User/Remote-Groups/Remote-Name/Remote-Email headers through once authenticated. authelia/config/configuration.prod.yml's access_control already defaults every *.burnttech.com host to two_factor, so a newly deployed prod app is covered as soon as the label is added — no Authelia-side config change needed unless the app needs a different policy (e.g. one_factor, or a bypass for a public health endpoint), in which case add a rule for that specific domain: '<container-name>.burnttech.com' above the wildcard rule in authelia's config/configuration.prod.yml (rules are evaluated in order, first match wins). Dev has the equivalent *.dev.burnttech.com -> two_factor rule in the base config/configuration.yml, so the same label works identically in dev if the app wants auth gating there too.

5. Report back

After wiring the app, report:

  • the app repo and the three files changed (Dockerfile, docker-compose.yml, .gitlab-ci.yml),
  • the dev hostname the app will be reachable at once deployed (<container>.dev.burnttech.com or the explicit Host(...) override),
  • the runner tags used per stage (ci for test, dev-env for build+deploy) and the default-branch gating,
  • the registry tags the build pushes (:<sha> + :dev),
  • whether a production deploy path was added (§4): the prod hostname (<container>.burnttech.com), the proxmoxdocker3 runner tag, the gating (manual by default), and whether it's wired behind Authelia,
  • the state of the first real dev pipeline (green, or the failing job + the diagnosis above), if the merge was made.

clear-backlog

Use when the user wants every open issue in a GitLab repo taken to merged and deployed in one pass — triggers on phrases like "clear the backlog", "complete all the issues", "work through every open ticket", "finish off this repo's issues", "burn down the backlog".

npx skills add git@git.burnttech.com:infra/ai-plugin.git clear-backlog
Triggers:
clear the backlog complete all the issues work through every open ticket finish off this repo's issues burn down the backlog
Full documentation

clear-backlog

Take an entire GitLab repo's open-issue backlog from "assigned" to "merged, deployed, and verified" — one dispatch loop over execute-ticket, run across as many issues at once as the repo's runners and dependency graph allow, with failures escalated per-ticket instead of stalling the batch.

This skill owns backlog → dispatch → aggregate report. It does not implement tickets itself and does not re-derive the land-and-verify loop — execute-ticket (which hands off to merge-and-validate) still owns one ticket end-to-end. Running many tickets in the same working tree at once would race the same git checkout; the one thing this skill adds is giving each ticket its own worktree and coordinating the fan-out with Claude Code's built-in orchestration skill (invoked via the Skill tool with orchestration — it is not shipped in this repo, so npm run validate does not track it as a cross-skill reference).

1. Check prerequisites

glab auth status
git status
git remote -v

If not authenticated, or the current directory has no GitLab remote, ask the user for the target project (-R namespace/repo) before continuing — every glab call below should include it explicitly unless the local remote already points at the right project. This skill dispatches into fresh worktrees per ticket, so uncommitted changes on the current branch are not touched, but confirm with the user before starting if git status is dirty — a stray uncommitted change usually means in-progress work that shouldn't be mistaken for backlog.

2. Enumerate the backlog

glab issue list --state=opened

Apply any filters the user gave (assignee, label, milestone) as flags on the same command rather than filtering client-side. Drop:

  • Issues that already have an open MR (glab mr list --source-branch=<num>-* covers the branch naming execute-ticket uses) — treat those as in-progress resumes rather than fresh dispatches; execute-ticket already resumes a matching branch instead of re-branching.
  • Issues explicitly out of scope for direct implementation — epics, or issues whose description says "blocked by #N" where #N is still open. Note the blocking relationship and dispatch the blocker first; don't dispatch both at once and let them race on the same files.

Show the user the resulting list (issue number, title, any blocking relationships found) before dispatching — this is the batch's scope, and fanning out N tickets' worth of branches, MRs, and CI runs is visible, resource-consuming work worth a one-line confirmation.

3. Set up one worktree per issue

Each dispatched ticket needs its own working tree so parallel runs don't fight over git checkout or uncommitted state in the shared repo:

git worktree add ../<num>-<slug> -b <num>-<slug> origin/<default-branch>

Use the same <num>-<slug> branch-naming convention execute-ticket uses, so a resumed or manually-inspected branch is recognizable. Name the worktree directory the same as the branch for the same reason.

4. Dispatch via orchestration

Use Claude Code's built-in orchestration skill to fan the filtered issue list out as a task per issue, each task running execute-ticket with its worktree as the working directory and the issue number as the argument. Respect any blocking relationships found in step 2 by making a blocked task depend on its blocker's completion rather than dispatching both immediately.

Cap concurrency to what the GitLab runners can actually absorb — the ci-tagged runner pool is shared across every dispatched ticket's MR pipeline, and the dev-env runner is typically a single shell executor, so deploy-triggering merges from two tickets at once will queue rather than overlap regardless. A concurrency of 3-4 in-flight tickets is a reasonable default; ask the user if the repo's runner capacity is known to differ.

Each task's definition of done is execute-ticket's own: ticket pulled, branch implemented and pushed, MR opened, MR pipeline green, merged, downstream deploy pipeline green, deployed environment validated. Do not shortcut a task to "MR opened" — a half-landed ticket left mid-pipeline is worse than not having started it, since it occupies the branch name and blocks a clean retry.

5. Handle per-ticket escalation without stalling the batch

execute-ticket and merge-and-validate already stop and ask rather than guessing on: an ambiguous ticket, a CI failure that isn't clearly fixable, or a validation failure needing design input. When a dispatched task hits one of these, let orchestration surface it as that task's escalation — do not let one stuck ticket block the others from finishing. Continue dispatching and collecting the remaining tasks, and hold the escalated ones open for the final report.

If a task fails outright (not an escalation — an unrecoverable error, like the worktree branch already existing from a previous failed run), clean up its worktree and mark it failed rather than silently retrying:

git worktree remove ../<num>-<slug> --force
git branch -D <num>-<slug>

Only run that cleanup for a task this skill dispatched and that failed before opening an MR — never for a task still escalated to the user, since the branch and any partial work are exactly what a human needs to look at.

6. Report back

One summary covering the whole batch:

  • total issues considered, and how many were dropped in step 2 (already in-progress, blocked, out of scope) with the reason for each,
  • for each dispatched ticket: issue number, terminal outcome (merged + deployed + validated, or escalated with the reason and what the user needs to decide, or failed with the cleanup taken),
  • any dependency ordering applied (ticket X held for blocker Y),
  • the worktrees left behind for any escalated or failed ticket, so the user knows where to look (git worktree list).

Do not report the batch as "done" while any ticket is still escalated — summarize it as partially complete with the specific tickets needing human input called out.

execute-ticket

Use when the user wants to implement a GitLab ticket end-to-end — pull the ticket, create a branch, do the work, commit with conventional commits, push, and open a merge request, then hand off to the `merge-and-validate` skill to drive the MR pipeline to green, merge, monitor the downstream deployment pipeline for merge-introduced regressions, and validate the deployed environment.

npx skills add git@git.burnttech.com:infra/ai-plugin.git execute-ticket
Triggers:
execute ticket #42 work ticket 17 implement this ticket pick up the current ticket start #X let's do ticket 12
Full documentation

execute-ticket

Take a GitLab ticket from "assigned" to "merged, deployed, and verified" — pull its details, branch, implement, commit with conventional commits, push, open the MR, then hand the landing half to the merge-and-validate skill, which drives the MR pipeline to green, merges, watches the downstream deployment pipeline, and validates the deployed environment.

This skill owns ticket → committed branch → MR opened. merge-and-validate owns MR → merged → deployed → validated. The split keeps one definition of the landing loop instead of two that drift apart; do not re-implement its steps here.

1. Check prerequisites

glab auth status
git status

If not authenticated, or the current directory has no GitLab remote, ask the user for the target project (-R namespace/repo) before continuing — every glab call below should include it explicitly unless the local remote already points at the right project. If git status shows uncommitted changes unrelated to this ticket, stop and ask the user how to handle them before switching branches — don't stash or discard silently.

2. Identify the ticket

  • If a ticket number/reference was given (e.g. "execute ticket #42", "work on 17"), that's the target.
  • If none was given, check whether the current branch already encodes one — branches created by this skill are named <num>-<slug>. Parse the leading number and treat that as "the current ticket."
  • If neither applies (no argument, and the current branch isn't a ticket branch), list open tickets assigned to the user and ask which one:
    glab issue list --assignee=@me --state=opened
    

Pull the full ticket once identified:

glab issue view <num>

Read its description and acceptance criteria fully before writing any code — they define "done" for this ticket.

3. Set up the branch

If the current branch already matches <num>-* for this ticket, stay on it — this is a resume, not a fresh start. Otherwise, start clean from the default branch:

git fetch origin
git checkout <default-branch>
git pull
git checkout -b <num>-<slug>

Derive <slug> from the ticket title: lowercase, non-alphanumeric runs collapsed to a single -, trimmed of leading/trailing -, capped around 50 characters. E.g. ticket 42 "Add rate limiting to /login endpoint" → 42-add-rate-limiting-to-login-endpoint.

4. Implement the change

Work the ticket's acceptance criteria one at a time. Keep the diff scoped to what the ticket describes — if you discover unrelated cleanup along the way, don't fold it into this branch; note it for a follow-up ticket instead.

5. Commit with conventional commits

Use <type>(<scope>): <subject> for every commit — feat, fix, refactor, test, docs, chore, build, ci, perf, matching whatever the change actually is. Keep the scope to the affected component/module. Prefer several small, logically-scoped commits over one giant one; each should leave the tree in a working state.

git add <specific files>
git commit -m "feat(auth): add rate limiting to /login endpoint"

Referencing the ticket from a commit

GitLab honours closing keywords in commit messages, not just MR descriptions: a commit whose message contains Closes #<num> closes that ticket when it lands on the default branch. Put it in a trailer on the final commit of the branch — the one that completes the ticket — and use Refs #<num> on the intermediate ones:

git commit -m "$(cat <<'EOF'
feat(auth): add rate limiting to /login endpoint

Closes #42
EOF
)"

Same rule as the MR description: Closes only when the change fully resolves the ticket, Refs otherwise. Carrying it on both the commit and the MR description is harmless — GitLab closes the ticket once — so prefer the MR description as the primary reference and treat the commit trailer as the way to keep the link visible in git log after the MR is gone.

Follow standard git safety norms: create new commits rather than amending, don't skip hooks (--no-verify), don't force-push.

6. Push and open the merge request

Show the user a one-line summary of what will be pushed and the MR title/description before doing either — like ticket creation, opening an MR is visible to the whole team (notifies watchers, triggers CI) and shouldn't happen silently.

git push -u origin <num>-<slug>
glab mr create \
  --title "<ticket title>" \
  --description "$(cat <<'EOF'
Closes #<num>

<brief summary of the change and how it satisfies the ticket's acceptance criteria>
EOF
)"

Closes #<num> auto-closes the ticket when the MR merges to the default branch — only include it if this MR fully resolves the ticket; use Refs #<num> instead for partial work.

7. Land it — hand off to merge-and-validate

Everything from here — watching the MR head pipeline to green, fixing and repushing on failure, merging, watching the downstream deployment pipeline the merge triggers, and validating the deployed environment — is the merge-and-validate skill. Invoke it now for this branch rather than re-deriving the loop.

That skill starts where this step leaves off: it expects the implementation committed on the current branch and an MR that either exists (it reuses the one created in step 6) or does not (it opens one). Both are true here, so it resumes cleanly.

What it does on this branch, in order:

  1. Monitors the MR head pipeline to a terminal state (via mr-pipeline-check), fixing clearly-scoped failures on the branch and repushing until green.
  2. Merges once green — a green head pipeline is the agreed merge signal, no second confirmation.
  3. Watches the downstream default-branch pipeline the merge creates. This runs against the integrated tree, so it catches regressions the branch pipeline structurally cannot: a build step that conflicts with the default branch, a deploy job gated to the default branch, a migration colliding with another merged MR. A green MR pipeline does not imply a green deploy.
  4. Validates the deployed environment against a project smoke script or a health probe — the step this skill previously lacked entirely.

Carry two things across the handoff so it does not re-derive them: the ticket number (so it keeps Closes #<num> semantics and reports the ticket) and the MR iid/URL from step 6.

Stop conditions are that skill's: a failure it cannot clearly scope, a protected default branch blocking a follow-up fix, or a red validation. Do not paper over any of them — surface them in step 8 as it hands them back.

8. Report back

Give the user one summary covering both halves — the implementation this skill did, and the landing merge-and-validate did:

  • the ticket number and branch name,
  • what changed, mapped back to the ticket's acceptance criteria,
  • the MR URL (glab mr create prints it on success),
  • everything merge-and-validate reports back from step 7: the MR head pipeline's terminal status, the merge outcome and merge commit SHA, the downstream deployment pipeline's terminal status, and the post-deploy validation result (which surface ran, against which URL),
  • any fix→repush iterations, with the failing job and its log URL,
  • anything either half stopped on and escalated, with the URLs a human needs.

If merge-and-validate stopped short, this skill's report says so plainly — the ticket is not done until the deploy pipeline is green and the deployed environment validated.

merge-and-validate

Use when the user wants to land a committed branch — open (or reuse) the merge request, drive the MR pipeline to green (fixing the local branch and repushing on failure), merge, monitor the downstream deployment pipeline (fixing regressions and repushing on failure), then validate the deployed environment against a health endpoint or project-provided smoke/validate test — running a fix→repush→revalidate loop until the deployed env is verified green.

npx skills add git@git.burnttech.com:infra/ai-plugin.git merge-and-validate
Triggers:
merge and validate this land and verify this branch merge and check the deployed env ship this and smoke-test dev merge, deploy, and validate
Full documentation

merge-and-validate

Take a committed branch to "merged, deployed, and the deployed environment verified" — open (or reuse) the merge request, drive the MR head pipeline to green and merge, watch the downstream deployment pipeline the merge triggers, then validate the deployed environment (the dev URL behind Traefik) against a health endpoint and, when the project provides one, a smoke/validate script. If any phase fails, fix the cause on the right branch, push, and re-watch / re-validate — iterate until green, then report.

This skill owns MR creation through post-deploy validation as one loop. It does not write feature code — assume the implementation is already committed on the branch you give it. For ticket-driven flows that start earlier (pull the ticket, branch, implement), use execute-ticket instead; this skill is the "land and verify" half when the work is already done and just needs shipping + verification.

1. Check prerequisites

glab auth status
git status
git remote -v

If not authenticated, or the current directory has no GitLab remote, ask the user for the target project (-R namespace/repo) before continuing — every glab call below should include it explicitly unless the local remote already points at the right project. If git status shows uncommitted changes, review them and commit the ones that clearly belong to this branch's work using conventional commits, without asking for permission. Two things are not covered by that: files unrelated to this branch's change (someone else's work in progress, stray scratch files, anything under a path this branch never touched) and anything that looks like a secret or a local-only config — stop and ask rather than sweeping those into the commit. Never git add -A blind; stage by path.

The branch you intend to land must be the current branch, fully committed and push-clean before you start. If it has no commits ahead of the default branch, there is nothing to land — stop and say so rather than opening an empty MR.

2. Capture the branch contract

Identify, before any MR/pipeline work, the four values steps 3–6 reference by value rather than by re-deriving:

  • The current branch — git rev-parse --abbrev-ref HEAD and git log origin/<default-branch>..HEAD --oneline. This is the MR source. Resume semantics in step 3 depend on whether an MR already points at it.

  • The default branch — glab api "projects/:fullpath" | jq -r '.default_branch'. Downstream deploy pipelines run against this branch post-merge; your follow-up fix commits in steps 5 and 6 land on it directly.

  • The deployed URL — read the compose file for the Traefik labels and healthcheck. Check all the spellings in use across these repos: docker-compose.yml, compose.yaml, docker/docker-compose.yml (ls docker-compose.y*ml compose.y*ml docker/docker-compose.y*ml 2>/dev/null). If the project has no compose file at all, it has no deployed environment — say so now and skip step 6 rather than inventing a URL to probe. The dev host is the Host(...) value from traefik.http.routers.<svc>.rule, or, if there is no explicit Host(...) label, <container-name>.dev.burnttech.com (the Traefik provider's defaultRule — see the add-traefik-deploy skill for the full contract). The path is whatever the service's healthcheck.test curls internally (e.g. /health). Assemble the full URL now — step 6 validates it.

  • A project-provided validation surface — look, in this order, for a stricter smoke/validate test the project ships, and prefer it over a bare health curl (the deploy pipeline's healthcheck already passed, so a stricter project script is what actually verifies behavior):

    1. What the deploy job already runs — the most reliable signal, because it is a script the project actually maintains. Grep the pipeline for it: grep -nE '(health|smoke|validate)[^:]*\.(sh|py)' .gitlab-ci.yml. ~/git/stocker calls ./ci/check-health.sh from every deploy job this way. Re-running that script against the deployed URL is a stronger probe than any name-based guess below.
    2. An executable script, by conventional name: scripts/smoke-test.sh, scripts/validate-deploy.sh, scripts/smoke.sh, ci/check-health.sh, ci/smoke.sh (check ls -la scripts/ ci/ for the executable bit).
    3. A package.json script named smoke, smoke:test, validate:deploy, or validate (Node) — jq '.scripts // {} | keys' package.json.
    4. A Makefile target smoke / validate-deploy — grep -E '^(smoke|validate-deploy):' Makefile.
    5. A tests file named smoke* or validate-deploy* under tests//Tests/.

    If one exists, note its path and how it takes the target URL (env var like BASE_URL is the common shape, occasionally a positional arg). If none exists, note it explicitly: step 6 will fall back to a health curl and the final report must flag this as a liveness-only check.

3. Open or reuse the merge request

A green MR pipeline is the merge signal here, so the MR has to exist before you can watch anything. Resume if it already does:

glab mr list --source-branch=<branch>

glab mr list lists open MRs by default; there is no --state flag (verified on glab 1.109.0 — passing it errors with Unknown flag: --state). To include closed/merged MRs, use --all, or go through the API: glab api "projects/:fullpath/merge_requests?source_branch=<branch>&state=opened".

  • If an MR is returned, reuse it — capture its <mr-iid> and the printed MR URL, and skip to step 4. Don't open a second MR for the same branch.

  • If none, show the user a one-line summary of what will be pushed and the MR title/description, then push and create it. Opening an MR is visible to the whole team (notifies watchers, triggers CI) — don't do it silently:

    git push -u origin <branch>
    glab mr create \
      --title "<short summary of the change>" \
      --description "$(cat <<'EOF'
    <brief summary of the change>
    
    Validates post-deploy via: <smoke script path, or "health curl against <dev URL>">
    EOF
    )"
    

    If the branch carries a ticket (number-prefixed branch like <num>-<slug>), include Closes #<num> (or Refs #<num> for partial work) in the description so the ticket closes on merge — same convention as execute-ticket.

Capture the MR <mr-iid> and the printed MR URL from the create output.

4. Monitor the MR pipeline — fix local branch + repush on failure

Watch the MR's head CI pipeline to a terminal state before merging. Use the mr-pipeline-check skill's polling mechanics — never wrap a while/until loop into a single Bash call. Two-step cycle: background sleep 15 (so you're notified, not blocking) → one-shot re-check, repeated. Back the interval off (15s → 30s → 60s) for slow CI. Terminal states: success, failed, canceled, skipped, manual. Only success is mergeable.

One-shot check (run first to capture the pipeline id/status):

glab api "projects/:fullpath/merge_requests/<mr-iid>" | jq '{iid, state, merge_status, sha, head_pipeline: {id: .head_pipeline.id, status: .head_pipeline.status}}'

Then follow mr-pipeline-check's wait cycle until terminal.

  • If the MR pipeline fails, do not merge a red MR. Pull the failed job logs to diagnose:

    glab api "projects/:fullpath/pipelines/<pipeline-id>/jobs" | jq '.[] | select(.status=="failed") | {name, stage, web_url}'
    

    Read the failing job's web_url logs. If the cause is clearly fixable within this branch's scope (lint error, broken test, missed import, wrong env), fix it on the local branch, commit with a conventional commit (fix:, test:, …), and push:

    git add <files>
    git commit -m "fix(<scope>): <what was wrong>"
    git push
    

    The push creates a new head pipeline on the MR — re-watch it via the same cycle. Repeat fix → push → re-watch until the head pipeline is green.

  • If the failure isn't clearly fixable — flaky infra, unclear cause, or a fix requiring changes outside this branch's scope — stop and ask the user rather than guessing indefinitely. Surface the failing job, its stage, and the web_url so a human can decide.

5. Merge and monitor the downstream deployment pipeline

When the MR head pipeline is green, merge it automatically (no second confirmation needed — pipeline green is the agreed merge signal):

glab mr merge <mr-iid> --remove-source-branch --yes

The flag is --remove-source-branch (short -d), not --remove-source — verified on glab 1.109.0, where the shorter form errors with Unknown flag: --remove-source. --yes skips the confirmation prompt, which otherwise blocks a non-interactive merge.

Capture the merge commit SHA (glab api "projects/:fullpath/merge_requests/<mr-iid>" | jq -r .merge_commit_sha).

Merging creates a new pipeline on the default branch for that merge commit SHA — this is the deployment/delivery pipeline that runs against the integrated tree, not the feature branch in isolation. Bugs that only surface after integration (a test or build step that passed on the branch but conflicts with the default, a deploy step that only runs on the default branch, a schema migration colliding with another merged MR) show up here, so this pipeline must be watched explicitly — a green MR pipeline does not guarantee a green deploy. Switch to the default branch locally and re-watch the new pipeline:

git fetch origin
git checkout <default-branch>
git pull

Use mr-pipeline-check again:

  1. Wait for the pipeline to be created — right after a merge GitLab takes a few seconds. Two-step cycle: background sleep 10 → glab api "projects/:fullpath/pipelines?sha=<merge-commit-sha>" | jq -r '.[0].id // empty' → repeat until non-empty.
  2. Wait that pipeline to a terminal state — two-step cycle: background sleep 15 (back off 15s → 30s → 60s for long-running deploys) → one-shot glab api "projects/:fullpath/pipelines/<pipeline-id>" | jq -r '.status' → repeat until terminal.
  • If the downstream pipeline succeeds, proceed to step 6 — deployment is green, now verify the deployed environment.
  • If it fails, treat it as a likely regression introduced by the merge. The MR's CI only ran against the feature branch; this pipeline runs against the integrated default branch, so a failure here means the merge introduced or exposed a bug.
    1. Inspect the failed jobs:

      glab api "projects/:fullpath/pipelines/<pipeline-id>/jobs" | jq '.[] | select(.status=="failed") | {name, stage, web_url}'
      
    2. Read the failing job logs at their web_url. If it's a clearly fixable one-liner (a conflict resolution that dropped a needed line, a deploy config that diverged, a missed env var), commit the follow-up directly to the default branch and push:

      git add <files>
      git commit -m "fix(<scope>): <regression cause>"
      git push
      
    3. A direct-to-default push creates a new downstream pipeline for the new commit — re-watch it via the same cycle. Repeat until green, then proceed to step 6.

      If the push is rejected — GitLab: You are not allowed to push code to protected branches on this project — the default branch is protected and step 5.2's direct commit is not available. Do not try to force it or to relax the protection. Fall back to a hotfix MR, which is the same loop one level down:

      git checkout -b fix-<slug>
      git push -u origin fix-<slug>
      glab mr create --title "fix: <regression cause>" --description "..."
      

      then re-enter step 4 for that MR, merge it, and re-watch the downstream pipeline it triggers. Note the extra hop in the final report.

    4. Otherwise — the regression isn't a clear one-liner, or a fix would be out of scope for this landing — stop and ask the user, opening a follow-up ticket if appropriate. Do not leave a broken deployment pipeline unreported, and do not proceed to step 6 over a red deploy.

6. Validate the deployed environment

The default-branch deploy pipeline going green only proves the deploy job succeeded — the container started and the in-container docker compose exec ... curl /health (or equivalent) returned 200. That healthcheck is a liveness probe, not a behavior check. Step 6 is the genuine post-deploy validation: hit the deployed URL behind Traefik (the one captured in step 2) with a real probe — ideally a project-provided smoke/validate script that exercises behavior the healthcheck can't.

Run validation

  • If a project-provided smoke/validate script was found in step 2 — run it, pointed at the deployed env. Most such scripts take the base URL via an env var; if the repo's existing script expects one, set it, otherwise pass the URL as the first arg. Examples (run only a script that actually exists in the repo — do not invent one):

    BASE_URL=http://the-app.dev.burnttech.com ./scripts/smoke-test.sh
    # or, for a package.json script that reads BASE_URL:
    BASE_URL=http://the-app.dev.burnttech.com npm run smoke
    # or, for a Makefile target:
    BASE_URL=http://the-app.dev.burnttech.com make validate-deploy
    

    Single command, no loop. A non-zero exit is a validation failure — proceed to the fix loop below.

  • If no project-provided script exists — fall back to a health probe against the deployed URL captured in step 2. Treat this as a deliberate liveness-only check and say so in the final report — it re-confirms what the deploy pipeline's healthcheck already passed; it does not verify behavior:

    curl --fail --silent --show-error http://the-app.dev.burnttech.com/health
    

    If the project genuinely has no validation surface, tell the user in the report that a scripts/smoke-test.sh (or equivalent) is the recommended next addition — this skill's validation loop is only as strong as the script it runs.

Fix loop on validation failure

A validation failure post-green-deploy means the runtime is misbehaving despite a healthy container: a smoke-test assertion failed, the app returns 500 to a behavior request, or the deployed URL doesn't match the compose labels. Diagnose by severity:

  1. Wrong URL / label mismatch — if the deployed URL in step 2 was misread (compose traefik.http.routers.<svc>.rule has a different Host(...), or the service listens on a different loadbalancer.server.port than you probed), fix step 2's captured URL and re-run validation — no code change needed.
  2. Clear runtime bug — an obvious 500 with a traceable cause in the container logs, or a smoke-test assertion that points at a specific function — fix the code, commit to the default branch, push, re-watch the new downstream deploy pipeline to green (step 5's cycle), then re-run validation (this step). If the default branch is protected and rejects the push, use step 5's hotfix-MR fallback instead. The full loop is fix → push → deploy green → revalidate. Repeat until validation passes.
  3. Not clearly fixable — the smoke test fails opaquely, you can't reach the container logs (no SSH to the dev host, or the deploy job's output didn't capture the runtime error), or the failure needs design input — stop and ask the user. Surface what you ran, the exact failure output, and the deployed URL. Do not claim "verified" after a red validation.

Reachable diagnostic commands

When the dev host is reachable over SSH, container logs are the fastest diagnostic for a runtime failure:

ssh <dev-host> 'docker compose -f ~/<repo>/docker-compose.yml logs --tail=200 <svc>'

If SSH isn't available, fall back to the deploy job's web_url logs — docker compose up -d and the exec ... curl /health lines are printed there, and a startup error before the healthcheck often surfaces in the job output. Do not treat "no logs reachable" as "validation passed."

When the project has no deployed environment

Some repos this skill is pointed at have no deploy path at all — a library, a plugin, a docs repo: .gitlab-ci.yml has only test/validate stages, there is no compose file, and no environment: block. There is nothing to validate and nothing to fall back to. Handle it explicitly:

  • Step 5's "downstream deployment pipeline" is still real — it is the default-branch pipeline the merge triggers — so watch it to terminal as usual. It just runs the same test suite against the integrated tree, which is still worth confirming.
  • Step 6 is not applicable, not "liveness-only". Report it as such. Do not curl anything, and do not recommend adding a smoke test to a repo that has nothing deployed to smoke-test.

7. Report back

Give the user a single summary:

  • the branch name and (if any) the ticket number,
  • the MR URL and <mr-iid>,
  • the terminal status of the MR head pipeline from step 4 (green, or the fix commits that got it there),
  • the merge outcome + merge commit SHA,
  • the terminal status of the downstream deploy pipeline from step 5 (green, or the fix commits + re-watch that got it there),
  • the validation outcome from step 6 — explicitly: which surface ran (project smoke script path + command, or "health curl fallback"), against which URL, and its terminal result. If it was the liveness-only fallback, say so and recommend adding a scripts/smoke-test.sh (or equivalent) for real behavioral validation,
  • any fix→repush→revalidate iterations across steps 4–6, with the failing job/smoke output that motivated each fix,
  • anything you stopped on and escalated to the user (a non-clearly-fixable failure at any phase), with the logs/URLs they need to decide.

mr-pipeline-check

Check a GitLab merge request's status and its pipeline(s), or wait for a pipeline to reach a terminal state, using `glab` — without ever writing an inline while/until loop into a single Bash call.

npx skills add git@git.burnttech.com:infra/ai-plugin.git mr-pipeline-check
Full documentation

mr-pipeline-check

Checks and waits use glab api (never glab ci status or glab mr view in a script — those are interactive TUI commands that hang when piped). Every Bash call below is a single, non-looping command. This is deliberate, not a style preference.

Why no inline loops

Claude Code, Antigravity CLI, and most agent harnesses flag any command containing loop or control-flow syntax (while, until, if, …) as "shell syntax that cannot be statically analyzed" and always prompt for approval — that check runs regardless of any allow/ask/deny rules, because the tool can't decompose a loop into matchable subcommands. Wrapping the same logic in a script file doesn't help unless the script is portable and pre-installed everywhere this skill runs, and it adds a chmod/shebang dependency. The fix used here instead: never submit a loop as one Bash call. Wait by repeating single plain commands.

One-shot checks (no waiting)

Check an MR's state and its head pipeline:

glab api "projects/:fullpath/merge_requests/<mr-iid>" | jq '{iid, state, merge_status, sha, head_pipeline: {id: .head_pipeline.id, status: .head_pipeline.status}}'

Check a known pipeline once:

glab api "projects/:fullpath/pipelines/<pipeline-id>" | jq -r '.status'

Both are single pipe commands (not loops) — pipes are a recognized separator, so each side is matched independently against existing Bash(glab *) / Bash(jq *) allow rules and these run without a prompt.

Waiting for a pipeline to reach a terminal state

Terminal states: success, failed, canceled, skipped, manual. Only success is a green result — treat manual as "blocked, needs a human" and skipped as "nothing ran", never as pass.

Repeat this two-step cycle — do not combine it into one Bash call:

  1. sleep 15 with run_in_background: true. Don't poll in the foreground; you'll be notified when it finishes.
  2. On notification, run the one-shot pipeline check above. If the status is terminal, stop and report it. Otherwise go back to step 1.

Back off the interval for long-running deploy pipelines (15s → 30s → 60s) rather than hammering the API.

Waiting for a pipeline to be created (by commit SHA)

Right after a push or merge, GitLab takes a few seconds to create the pipeline. Same two-step cycle:

  1. sleep 10 with run_in_background: true.
  2. On notification: glab api "projects/:fullpath/pipelines?sha=<sha>" | jq -r '.[0].id // empty'. If non-empty, you have the pipeline ID — proceed to waiting for a terminal state above. If empty, repeat from step 1.

Inspecting a failure

When a pipeline reaches failed, list the failed jobs and read their logs:

glab api "projects/:fullpath/pipelines/<pipeline-id>/jobs" | jq '.[] | select(.status=="failed") | {name, stage, web_url}'
glab api "projects/:fullpath/jobs/<job-id>/trace"

The trace endpoint returns the raw job log — prefer it over opening web_url in a browser when you need to diagnose non-interactively.

Requirements

Claude Code — add Bash(sleep:*) to permissions.allow in settings.json (user or project level) so the background sleep calls don't prompt. glab and jq calls are already covered by typical Bash(glab *) / Bash(jq *) allow rules — check /permissions if they still prompt.

Antigravity CLI — add command(sleep), command(glab), and command(jq) to the permissions.allow array in ~/.gemini/antigravity-cli/settings.json.

Known glab flag differences

Verified against glab 1.109.0:

  • glab mr list --source-branch=<branch> lists open MRs by default; there is no --state flag. Use --all, --closed, or --merged to widen it, or go through the API: glab api "projects/:fullpath/merge_requests?source_branch=<branch>&state=opened".
  • glab ci status and glab mr view (without --output json) render an interactive TUI and will hang when their output is piped. Always use glab api in an automated flow.

plan-ticket

Use when the user wants to create a GitLab ticket/issue, plan out work as tickets, or break a feature/task down into tickets — triggers on phrases like "create a ticket for X", "file a gitlab issue", "break this into tickets", "plan tickets for this", "ticket this up".

npx skills add git@git.burnttech.com:infra/ai-plugin.git plan-ticket
Triggers:
create a ticket for X file a gitlab issue break this into tickets plan tickets for this ticket this up
Full documentation

plan-ticket

Turn a feature/task description into one or more GitLab issues, sized so each one is an independently testable unit of work — not one giant issue, and not one issue per trivial sub-step either.

1. Check prerequisites

glab auth status

If not authenticated or the current directory has no GitLab remote, ask the user for the target project (-R namespace/repo) before continuing. Every glab issue create call below should include -R namespace/repo explicitly unless you've confirmed the local repo's remote already points at the right project.

2. Decide the breakdown

For the task at hand, ask: "can this be implemented, reviewed, and verified on its own, with its own PR and its own test/acceptance check?"

  • If the whole request already satisfies that as a single piece of work, create one ticket. Don't manufacture extra tickets just to look thorough.
  • If it doesn't — e.g. it spans multiple components, multiple independently-shippable behaviors, or would force an unreviewably large PR — split it into multiple tickets, one per testable unit. Each split-out ticket must stand on its own: a reviewer could pick it up without needing the others merged first, or if there's a real dependency, say so explicitly in the description.
  • If the total scope is epic-sized (a multi-milestone initiative, several unrelated ticket groups under one theme): do not create a ticket for the epic/initiative itself. Only the concrete, testable units become issues. Tie them together with a shared label instead (e.g. initiative::checkout-v2) so they're queryable as a group: glab issue list --label "initiative::checkout-v2". If the user's GitLab tier has native Epics and they ask for one specifically, that's an explicit exception — use glab api against the Epics REST/GraphQL endpoint rather than glab issue create.

3. Draft before creating

For each ticket, draft:

  • Title — short, action-oriented (e.g. "Add rate limiting to /login endpoint").
  • Description — brief context, then an explicit, testable acceptance criteria list. Phrase criteria as verifiable statements, not vague goals:
    • Good: "Returns 429 after 5 failed attempts within 60s from the same IP"
    • Bad: "Add rate limiting"
  • Labels — any shared grouping label (see epic handling above) plus type/area labels the project already uses (check glab label list if unsure).
  • Dependencies — if ticket B needs ticket A merged first, note it in B's description as Depends on #<A's iid> (fill in once A is created, see step 4).

Show the full draft list to the user and get confirmation before creating anything. Creating issues is visible to the whole team (notifies watchers, shows up in the tracker) — don't skip this check even if the user asked you to "just create the tickets," a quick one-line summary + go-ahead is enough.

4. Create the tickets

Create in dependency order so earlier IIDs exist to reference from later ones. Use a heredoc for the description to avoid quoting issues with multi-line text:

glab issue create \
  --title "Add rate limiting to /login endpoint" \
  --label "backend" --label "initiative::checkout-v2" \
  --description "$(cat <<'EOF'
Context: ...

Acceptance criteria:
- [ ] Returns 429 after 5 failed attempts within 60s from the same IP
- [ ] Rate limit resets after the window elapses
- [ ] Existing successful-login tests still pass
EOF
)"

For a ticket that depends on one just created, reference it by plain issue number in the description body (e.g. Depends on #42) — GitLab auto-links this as a reference, no quick-action syntax required.

5. Report back

After creating, list every ticket's number and URL (glab prints the URL on success) so the user has a single summary to click through, grouped under whatever shared label was used if this was epic-sized work.

review-recent-activity

Use when the user wants a quality audit of a GitLab project's recent work — every open merge request plus commits from the last few days — rather than a change they want implemented or landed.

npx skills add git@git.burnttech.com:infra/ai-plugin.git review-recent-activity
Triggers:
review all open MRs check recent commits are up to standard audit the last few days of work did everyone follow our conventions this week quality check the open MRs review recent activity
Full documentation

review-recent-activity

Audit a GitLab project's recent activity for standards compliance: every open merge request, plus every commit landed on the default branch in the last few days. This is a read-only quality gate, not a fix loop — it produces a report with a verdict per item so a human (or a follow-up execute-ticket / code-review --fix pass) can act on it. Do not push fixes, merge MRs, or close tickets as part of this skill. The one exception: mandatory findings (see step 4) can be handed to plan-ticket to become real, trackable issues — that's filing work for later, not fixing anything now, and still requires the user's go-ahead before anything is created (plan-ticket's own step 3 gate).

1. Check prerequisites and scope

glab auth status
git remote -v

If not authenticated, or there's no GitLab remote, ask for the target project (-R namespace/repo) before continuing — every glab call below should include it explicitly unless the local remote already points at the right project.

Resolve the two scope inputs before gathering anything:

  • The time window. Default to the last 3 days if the user didn't specify one ("last few days" → 3; honor an explicit number like "last week" → 7). Compute the cutoff once: git log --since="3 days ago" ... style, or an ISO date for the GitLab API's updated_after / created_after params.
  • The default branch — glab api "projects/:fullpath" | jq -r '.default_branch'. Recent-commit review walks this branch; MR review walks branches targeting it.

2. Gather open merge requests

glab api "projects/:fullpath/merge_requests?state=opened&order_by=updated_at" \
  | jq '[.[] | {iid, title, author: .author.username, source_branch, created_at, updated_at, description, draft}]'

For each MR, pull its diff stats and head pipeline status in the same pass — don't open each one individually in a browser:

glab api "projects/:fullpath/merge_requests/<mr-iid>/changes" \
  | jq '{changes_count: (.changes | length), files: [.changes[].new_path]}'
glab api "projects/:fullpath/merge_requests/<mr-iid>" \
  | jq '{head_pipeline: {status: .head_pipeline.status}, merge_status}'

Use mr-pipeline-check's one-shot check pattern for pipeline status — this is a point-in-time audit, not a wait, so don't poll; a single check is enough. If a pipeline is still running, report its current status as-is rather than waiting for it to finish.

3. Gather recent commits on the default branch

git fetch origin
git log origin/<default-branch> --since="<window>" --no-merges \
  --pretty=format:'%H|%an|%ad|%s' --date=short

--no-merges excludes the merge commits already covered by an MR in step 2 — those get reviewed as MRs, not as loose commits, to avoid double-reporting the same work. Anything landed by a direct push to the default branch (no MR) will show up here and is worth flagging on its own: this repo's convention (see AGENTS.md/CLAUDE.md if the target project has one) is normally MR-then-merge, so a direct push is itself a standards question, not just its content.

For each commit, pull its diffstat for the test/no-test check in step 4:

git show --stat --format='' <sha>

4. Evaluate each item against the standards checklist

Apply the same checklist to every open MR and every loose commit from step 3. Don't guess at a project's conventions — read its CLAUDE.md/AGENTS.md/ CONTRIBUTING.md if one exists and prefer its stated rules over the generic ones below.

Each item below is tagged mandatory or advisory. This distinction drives step 6: mandatory findings are the ones worth turning into a real ticket someone has to act on; advisory ones are worth reporting but not worth filing — don't create ticket noise for style preferences.

  • Commit message format (advisory). Conventional commits (<type>(<scope>): <subject>, e.g. feat(auth): add rate limiting) unless the project's own docs say otherwise. Flag subjects that are vague ("fix stuff", "wip", "updates") regardless of type prefix.
  • Description present and useful (advisory). An MR with an empty or single-word description is a flag — reviewers and future readers need the why, not just the diff. A loose commit's body counts as its description.
  • Ticket linkage (advisory). If the branch or commit is ticket-numbered (<num>-<slug>) or the project links tickets in MR descriptions, check for Closes #<num> / Refs #<num>. Missing linkage on a ticket-shaped branch is a flag, not a hard failure — some work legitimately has no ticket.
  • Tests included for behavior changes (mandatory). If the diffstat touches non-test/non-doc source files, check whether it also touches a test file. No test coverage for new logic is a flag; a pure refactor, config change, or docs-only change is exempt — say so explicitly rather than flagging it.
  • Pipeline status (mandatory). For an open MR, a red or stuck head pipeline is a flag on its own regardless of code quality; nothing else in the checklist matters until CI is green. Note it but don't block the rest of the review on it.
  • Scope and size (advisory). An MR touching an unrelated area from its stated purpose, or a diff so large it's implausible to review carefully (a rough guide: >500 changed lines with no natural split) is a flag — note it as "consider splitting," not as a defect.
  • Obvious leftovers (mandatory). Grep the diff for debug residue that shouldn't ship: console.log, stray print(/debugger, commented-out code blocks, TODO/FIXME without a ticket reference, and hardcoded secrets/credentials patterns. Use git show <sha> or the MR's /changes diff for this — don't re-clone or check out every branch. Hardcoded secrets/credentials are always mandatory regardless of anything else in this list; say so explicitly and call out the exact file/line so it can be rotated, not just removed.
  • Direct push to the default branch, bypassing an MR (mandatory). Every loose commit from step 3 carries this flag by construction — that's what makes it a "loose commit" rather than a reviewed MR. Note it as a process violation regardless of the commit's own content quality.
  • Staleness (advisory). An open MR with no activity (updated_at) in the review window is worth surfacing separately — not a quality defect, but useful for the report ("these have gone quiet").

This is a breadth pass, not a deep bug hunt — it's checking process and hygiene signals across many items quickly, not tracing every code path. If an MR looks substantively risky (complex logic change, touches auth/payments/ migrations) and the user wants a real correctness review, say so and offer a deeper diff-level review of that specific MR as a follow-up (e.g. via /code-review if the reviewing agent has it) — don't silently expand this pass into one.

5. Report back

One report, most-concerning items first. For each MR and each loose commit, give:

  • identifier (!<mr-iid> or short SHA), title/subject, author, age,
  • pipeline status (MRs only),
  • a verdict: clean, flag (list the specific checklist items that failed, tagging each mandatory or advisory, e.g. "mandatory: no tests for src/auth/login.js; advisory: no description"), or blocked (red/stuck pipeline),
  • one line of concrete evidence per flag — not just "tests missing" but "touches src/auth/login.js, no corresponding change under tests/".

Close with a short rollup: counts by verdict, a separate count of mandatory findings (these are candidates for step 6), any consistently-recurring advisory issue worth raising as a team norm instead of a ticket (e.g. "3 of 5 open MRs have no description — worth a reminder"), and explicitly note this was a read-only audit — nothing was fixed, merged, or commented yet.

6. File tickets for mandatory findings

If step 5 turned up zero mandatory findings, say so and stop here — don't invoke plan-ticket for an empty list.

Otherwise, list the mandatory findings back to the user as candidate tickets and ask whether to file them (a plain "yes, file these" or "just the security one" is enough — don't require a special phrase). Only proceed on an explicit go-ahead; a mandatory finding sitting unfiled is a fine outcome if the user just wanted visibility.

On confirmation, hand the confirmed list to the plan-ticket skill instead of calling glab issue create directly here — it owns ticket sizing, drafting, and the visible-to-the-team creation gate, and this skill shouldn't duplicate that logic. Group findings sensibly before handing off: one ticket per mandatory finding is usually right (e.g. "add missing tests for rate limiting in !42"), but multiple mandatory findings on the same MR or commit (e.g. missing tests and a hardcoded secret in the same diff) can be one ticket with multiple acceptance criteria — let plan-ticket's own "one testable unit" judgment call decide the final split. Give each drafted ticket a Refs !<mr-iid> / commit SHA back-reference in its description so it's traceable to this audit, and carry over any real ticket number the MR/commit was already tied to as Depends on #<num> or similar context.

Report the created ticket numbers/URLs (from plan-ticket's own step 5) as part of this skill's final summary. If the user declined to file, or filed only a subset, say explicitly which mandatory findings remain unfiled so they're not silently dropped.

If the user separately wants findings pushed back into GitLab as inline MR comments (as opposed to tickets), ask before posting — that's a distinct, visible action this skill does not do on its own.

setup-docker-pipeline

Use when the user wants a Docker build and deploy pipeline covering **both dev and production** deployment paths for an app that is **not** going behind the shared Traefik proxy in production (not user-facing over HTTP, or fronted some other way).

npx skills add git@git.burnttech.com:infra/ai-plugin.git setup-docker-pipeline
Triggers:
setup docker deployment create a build and deploy pipeline deploy to dev and prod add a production deploy job setup docker registry push and pull
Full documentation

setup-docker-pipeline

Wire an application to use a standard Docker build and deploy strategy via GitLab CI. This skill configures CI to build Docker images, publish them to the GitLab Container Registry, and deploy them to both dev (automatically on the default branch) and prod (manually triggered), pulling from the registry rather than rebuilding. If the application serves HTTP traffic to end users, it also integrates with the shared Traefik proxy.

Scope vs add-traefik-deploy

These two skills overlap and must not be applied together to the same repo:

If the app is… Use
user-facing over HTTP, going behind the shared Traefik proxy (dev only, or dev and production) add-traefik-deploy — fully prescriptive for both, verified against ~/git/mad-jars-web (dev) and ~/git/authelia (prod)
headed for production as well as dev but not going behind shared Traefik, or not user-facing at all this skill

Where both describe the same file, add-traefik-deploy is authoritative for the Traefik labels, the compose network wiring, and both the dev and production deploy jobs (its §4). This skill covers the same dev+prod pipeline shape for apps that aren't behind shared Traefik.

Assumptions & Dependencies

  • You are operating on a repo with a Dockerfile.
  • The GitLab instance has runners tagged ci (for tests/validation) and dev-env (for building/pushing to the registry and dev deploys).
  • For production deployments, the proxmoxdocker3 runner tag is the shell executor on the prod host (mirrors dev-env for dev — see traefik/authelia .gitlab-ci.yml), or docker if a DinD executor already handles prod on the instance (e.g. stocker reference).
  • If using Traefik, the dev host runs a shared Traefik proxy on the traefik external network (see canonical reference ~/git/traefik) — but if the app needs a production Traefik path too, use add-traefik-deploy instead of this skill (see scope table above).

1. Dockerfile

Ensure the Dockerfile builds a production-ready image:

  • Use a multi-stage build.
  • Run as a non-root user.
  • Expose the necessary port (e.g., EXPOSE 3000).
  • Ensure it defines a meaningful HEALTHCHECK if applicable.

2. docker-compose.yml

Create or update docker-compose.yml to specify how the application runs. It must use the published registry image via variable interpolation so pull doesn't rebuild.

services:
  app:
    # Required-variable interpolation, and no `build:` key. Both matter:
    #   * `:?` fails loudly if CI forgets to export the vars, instead of
    #     silently deploying a stale local `my-app:latest`.
    #   * a `build:` key lets `docker compose up` rebuild from source when the
    #     tag is not present locally — exactly the rebuild-on-deploy this
    #     skill's contract forbids. Build locally with an explicit
    #     `docker build -t "$CI_REGISTRY_IMAGE:dev" .` instead.
    image: "${CI_REGISTRY_IMAGE:?Set CI_REGISTRY_IMAGE}:${IMAGE_TAG:?Set IMAGE_TAG}"
    restart: unless-stopped
    # If the app has end users, join the shared traefik network
    networks:
      - traefik
    labels:
      - "traefik.enable=true"
      # Host will default to the defaultRule in Traefik, but can be specified explicitly:
      - "traefik.http.routers.my-app.rule=Host(`my-app.dev.burnttech.com`)"
      - "traefik.http.services.my-app.loadbalancer.server.port=3000"

networks:
  traefik:
    external: true

Note: If the application is NOT user-facing (e.g. a background worker), omit the networks: - traefik and labels blocks.

Production Compose (Optional: docker-compose.prod.yml)

If production requires different configuration (different secrets, resource limits, etc.), create a docker-compose.prod.yml that overrides the base. Every prod command must then name both files explicitly — docker compose -f docker-compose.yml -f docker-compose.prod.yml … — since docker compose does not pick the override up automatically (~/git/stocker does exactly this on every prod job). The prod deploy job below is written that way:

  • No host-published DB/infra ports (127.0.0.1:<port>:<port>).
  • Inject production secrets from GitLab variables into the container environment.

(If this app needs to sit behind the shared Traefik proxy in production — TLS, the *.burnttech.com domain, the proxmoxdocker3 runner, optional Authelia 2FA — that's add-traefik-deploy's §4, not this skill; see the scope table above.)

3. .gitlab-ci.yml

Create a .gitlab-ci.yml that strictly enforces the build publishes, deploy pulls contract.

stages:
  - test
  - build
  - deploy

variables:
  IMAGE_TAG: "$CI_COMMIT_SHA"

test:
  stage: test
  image: docker:27-cli
  tags:
    - ci
  script:
    # `image:` uses required-variable interpolation, so `config` needs both vars
    # set. CI_REGISTRY_IMAGE is predefined by GitLab when the registry is
    # enabled and IMAGE_TAG comes from `variables:` above, so this works in CI —
    # but locally you must export them first:
    #   CI_REGISTRY_IMAGE=my-app IMAGE_TAG=dev docker compose config --quiet
    - docker compose config --quiet
    # Add real testing (e.g., cargo test, npm test, etc.)

build_image:
  stage: build
  tags:
    - dev-env
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" --username "$CI_REGISTRY_USER" --password-stdin
    - docker build --pull --tag "$CI_REGISTRY_IMAGE:$IMAGE_TAG" --tag "$CI_REGISTRY_IMAGE:dev" .
    - docker push "$CI_REGISTRY_IMAGE:$IMAGE_TAG"
    - docker push "$CI_REGISTRY_IMAGE:dev"
  after_script:
    - docker logout "$CI_REGISTRY"

deploy_dev:
  stage: deploy
  tags:
    - dev-env
  needs:
    - build_image
  resource_group: dev-environment
  environment:
    name: dev
    url: http://my-app.dev.burnttech.com
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" --username "$CI_REGISTRY_USER" --password-stdin
    - export IMAGE_TAG="$CI_COMMIT_SHA"
    - docker compose pull
    - docker compose up -d --remove-orphans --wait
    # Explicit health check of the deployed service (adjust the port/path to match the app):
    # - docker compose exec -T app curl --fail --show-error --silent http://127.0.0.1:3000/health
  after_script:
    - docker logout "$CI_REGISTRY"

deploy_prod:
  stage: deploy
  # Pick ONE and say why in a comment — do not leave this ambiguous:
  #   `proxmoxdocker3` — the shell executor on the prod host, mirroring how
  #                      `dev-env` relates to the dev host. The correct target
  #                      for most apps (see `traefik`/`authelia` .gitlab-ci.yml).
  #   `docker`         — the DinD executor. Only if prod already runs there for
  #                      this project (`~/git/stocker` does). Never deploy prod
  #                      on `dev-env`: that runner is the dev host.
  tags:
    - proxmoxdocker3
  needs:
    - build_image
  environment:
    name: production
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
      when: manual
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" --username "$CI_REGISTRY_USER" --password-stdin
    - export IMAGE_TAG="$CI_COMMIT_SHA"
    # Both -f flags, every time: the override is not applied implicitly.
    - docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
    - docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --remove-orphans --wait
  after_script:
    - docker logout "$CI_REGISTRY"

Contract Checklist

  • Build publishes, deploy pulls. The deploy stage never builds images (it uses docker compose pull). Enforced structurally: the compose service has no build: key, so there is nothing for up -d to rebuild from.
  • Fail loud on missing vars. image: uses ${VAR:?…} required-variable interpolation, never ${VAR:-default} — an unset CI_REGISTRY_IMAGE must abort the deploy, not quietly ship a local image.
  • Override files are explicit. Any prod job that uses docker-compose.prod.yml passes -f docker-compose.yml -f docker-compose.prod.yml on every compose invocation (pull, up, logs, down).
  • Default-branch gating. deploy_dev runs automatically only on the default branch. deploy_prod runs manually.
  • Health check. The deploy scripts must wait for the containers to be healthy. If docker compose healthchecks aren't enough, execute a curl against the app endpoint inside the container.

troubleshoot-repo

Use when the user wants to diagnose or fix a failure in the current GitLab repository by correlating local code and configuration with merge-request, branch, deployment, or other pipeline job logs.

npx skills add git@git.burnttech.com:infra/ai-plugin.git troubleshoot-repo
Triggers:
troubleshoot this repo diagnose this pipeline why is CI failing inspect the failed jobs look through the pipeline logs fix the current pipeline
Full documentation

troubleshoot-repo

Turn an ambiguous "the pipeline is broken" report into an evidence-backed root cause. Inspect the current repository, select the pipeline that actually matches the user's context, read its raw job traces, and correlate the first causal failure with the exact revision and CI configuration that ran.

This skill has two modes:

  • Diagnosis mode is the default. Read local and GitLab state, reproduce safely when useful, and report the cause without changing files or external state.
  • Fix mode applies only when the user asks to fix, repair, or get the pipeline green. Make the smallest scoped change, validate it locally, commit and push it on a non-default branch, then use mr-pipeline-check to recheck the resulting pipeline. Repeat until green or until a stop condition below is reached.

Do not merge, deploy, retry/cancel jobs, change CI variables, or alter runner / project settings unless the user separately asks for that action. For a committed branch that should be merged, deployed, and smoke-tested after it is green, hand off to merge-and-validate.

1. Establish the local and GitLab context

Start with read-only checks:

glab auth status
git status --short --branch
git remote -v
git rev-parse --show-toplevel
git rev-parse --abbrev-ref HEAD
git rev-parse HEAD

Resolve and retain these values rather than guessing them again later:

  • project path and default branch: glab api "projects/:fullpath" | jq '{path_with_namespace, default_branch, web_url}'
  • current branch and local SHA,
  • upstream branch and its SHA, when configured: git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' and git rev-parse '@{upstream}',
  • whether the worktree contains tracked or untracked changes.

Authentication or remote failure is a real blocker: report it and ask for the project (-R namespace/repo) if it cannot be derived. A dirty worktree is not a blocker for diagnosis, but never attribute uncommitted contents to a pipeline; GitLab ran a commit, not the current worktree. Do not stash, discard, or include unrelated changes.

Read the repository's operating contract before interpreting the failure:

find .. -name AGENTS.md -print
ls -la
sed -n '1,240p' .gitlab-ci.yml

Also inspect only the files relevant to the failing job: included CI YAML, scripts called by that job, package.json, language manifests, Dockerfiles, compose files, and the applicable AGENTS.md. Do not assume a test/build/deploy command from another project.

2. Select the right pipeline

Use this precedence and say which match rule selected the pipeline:

  1. A pipeline ID, job URL/ID, MR, SHA, or branch explicitly named by the user.
  2. The current branch's open MR head pipeline.
  3. The newest pipeline whose ref matches the current branch and whose SHA is the local/upstream SHA.
  4. The newest default-branch pipeline only when the user described a post-merge, deployment, or default-branch regression.

Do not silently diagnose the newest project-wide pipeline: it may belong to another developer or revision.

Find an MR for the current branch and inspect its head pipeline:

glab mr list --source-branch=<branch>
glab api "projects/:fullpath/merge_requests/<mr-iid>" | jq '{iid, web_url, source_branch, sha, head_pipeline: {id: .head_pipeline.id, status: .head_pipeline.status, web_url: .head_pipeline.web_url}}'

If there is no open MR, list branch pipelines and match both ref and SHA:

glab api "projects/:fullpath/pipelines?ref=<branch>&per_page=20" | jq '.[] | {id, status, ref, sha, source, created_at, web_url}'

For a named job ID, resolve its pipeline before continuing:

glab api "projects/:fullpath/jobs/<job-id>" | jq '{id, name, status, stage, web_url, pipeline}'

If no pipeline matches the current commit, report the SHA mismatch explicitly. Do not pretend a stale pipeline represents the current code. If the matching pipeline is still running, use mr-pipeline-check only when the user asked to wait; otherwise report its current state and continue with already-failed jobs.

3. Build the failure timeline from GitLab

Capture pipeline metadata, then list all jobs including retried attempts:

glab api "projects/:fullpath/pipelines/<pipeline-id>" | jq '{id, status, ref, sha, source, created_at, updated_at, web_url}'
glab api "projects/:fullpath/pipelines/<pipeline-id>/jobs?include_retried=true&per_page=100" | jq '.[] | {id, name, stage, status, failure_reason, allow_failure, started_at, finished_at, duration, runner: .runner.description, web_url}'
glab api "projects/:fullpath/pipelines/<pipeline-id>/bridges?per_page=100" | jq '.[] | {id, name, status, web_url, downstream_pipeline}'

Inspect a downstream pipeline referenced by a failed bridge as its own pipeline; the bridge log alone often contains no root cause.

Read the raw trace for every failed job and for any earlier job whose artifacts or output the failed job consumes:

glab api "projects/:fullpath/jobs/<job-id>/trace"

Start with the earliest non-cascade failure, not the last red job. Distinguish:

  • the first actionable error from cleanup noise and repeated stack traces,
  • a job script failure from runner/system failure (failure_reason, missing runner, image-pull, executor, network, registry, or timeout evidence),
  • a required failure from an allow_failure job,
  • a canceled/manual/skipped job from a test failure,
  • a child-pipeline failure from its parent bridge status.

Do not expose masked-looking secrets or reproduce credential values in the report. Quote only the small log excerpt needed to identify the failure.

4. Correlate the trace with the revision that ran

Verify that the selected pipeline SHA exists locally:

git cat-file -e <pipeline-sha>^{commit}
git show --stat --oneline <pipeline-sha>
git diff <pipeline-sha>..HEAD --

Fetch normally if the commit is absent and the remote is trusted. If fetching would overwrite nothing but still needs network approval, request it; do not switch branches or disturb the worktree merely to inspect a historical commit. Use git show <pipeline-sha>:<path> to read the CI file or script as it existed in that pipeline. Local HEAD files are evidence only when the SHAs match.

Trace the failing command from the raw log back through .gitlab-ci.yml, its include files, extends, variables, rules, needs, and the invoked script. Then inspect the commits that could explain the change:

git log --oneline --decorate -20 <pipeline-sha>
git diff <known-good-sha>..<pipeline-sha> -- <relevant-paths>
git blame <pipeline-sha> -- <relevant-path>

Use a known-good SHA only when GitLab history or the user identifies one. A nearby green pipeline can be a comparison point, but account for changes in CI variables, runners, services, and external dependencies before blaming code.

5. Reproduce the smallest faithful failure

Run the exact lint/test/build command found in the selected revision's job or script, narrowed to the failing target when possible. First install dependencies only if the repo's documented workflow requires it and the user has authorized the needed network/write effects.

Keep reproduction honest:

  • record when local HEAD, dependency state, platform, container image, or environment differs from the pipeline,
  • prefer the repository's existing validation scripts over invented commands,
  • never print protected variables or ask the user to paste secrets,
  • do not run a deploy job, destructive migration, release, or registry push as a local reproduction,
  • treat "passes locally" as evidence of an environment difference, not proof that the pipeline is flaky.

Classify the root cause only when the evidence supports it: repository code, test expectation, CI configuration, dependency/toolchain drift, missing or mis-scoped GitLab variable, runner capacity/configuration, external service, or genuine flake. If several remain possible, list the discriminating check rather than claiming certainty.

6. Fix and recheck only in fix mode

Before editing, confirm the pipeline's SHA belongs to the current non-default branch. If the failing pipeline is historical, belongs to another branch, or ran on the default branch, create/use an appropriately scoped fix branch only when that is within the user's request. Never commit directly to the default branch as part of generic troubleshooting.

If existing worktree changes overlap the proposed fix and their ownership is unclear, stop and ask. Otherwise:

  1. Make the smallest change that addresses the evidenced root cause.
  2. Run the focused reproduction, then the repository's normal relevant validation suite.
  3. Review git diff and git diff --check; stage only explicit paths.
  4. Commit conventionally, for example fix(ci): install dependency before integration tests.
  5. Show the user the diagnosis and one-line push summary, then push the branch.
  6. Locate the new pipeline by the pushed commit SHA and use mr-pipeline-check to wait it to a terminal state.

If it fails for the same root cause, revisit the diagnosis before making a second speculative change. If it fails for a new clearly-scoped cause, repeat the evidence → minimal fix → local validation → push → recheck cycle.

Stop instead of mutating when the remedy requires project/runner settings, protected variables, secret rotation, canceling/retrying jobs, broad dependency upgrades, production access, or unrelated code changes. Report the exact change needed and ask for authorization or the responsible owner. Also stop after two failed fix iterations for the same unresolved cause; preserve the logs and ask for direction rather than churning commits.

7. Report the evidence and outcome

Give one concise troubleshooting report containing:

  • project, branch/ref, pipeline ID/URL, pipeline SHA, and whether it matches the local/upstream SHA,
  • the failing job(s), job URL(s), and the first causal error rather than only the final symptom,
  • the root cause with confidence level and the repository/log evidence that supports it,
  • whether and how the failure reproduced locally, including material environment differences,
  • diagnosis-only next action, or every fix commit and replacement pipeline in fix mode,
  • the terminal recheck result, if waiting was requested,
  • blockers requiring user or administrator action.

Never call a pipeline fixed merely because the local command passes. In fix mode, "fixed" means the replacement GitLab pipeline reached success; in diagnosis mode, say "diagnosed" and leave mutation to the user.

No skills match your search.