# Github Actions Dev

> Develop and test reusable GitHub Actions in the loft-sh/github-actions repository. Use when adding new actions, modifying existing ones, running tests, or releasing action versions. Triggers on work in ~/loft/github-actions or github-actions worktrees.

- Skill: `loft-sh/github-actions-dev` (Agent Skill)
- Install (CLI): `npx skillmds@latest add loft-sh/github-actions-dev`
- Raw SKILL.md: https://api.skillmd.com/api/skills/loft-sh/github-actions-dev/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: loft-sh (https://skillmd.com/u/loft-sh)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/loft-sh/github-actions-dev

---


# github-actions Repository Development

Repo: `loft-sh/github-actions` — reusable GitHub Actions for loft-sh org.

## Repo structure

```
.github/
  actions/           # reusable actions (each in its own directory)
    semver-validation/     # composite over the semstat binary, bats tests
    linear-pr-commenter/   # composite, go tests
    release-notification/  # composite, yaml-only (no testable logic)
    ci-notify-nightly-tests/  # composite, yaml-only
  workflows/         # CI and reusable workflows
Makefile             # local test harness
```

## Running tests

```bash
make test                       # all action tests
make test-semver-validation     # semver-validation bats only
make test-linear-pr-commenter   # go tests only
make lint                       # actionlint + zizmor
```

## Adding a new action

1. Create `.github/actions/<action-name>/action.yml`
2. Add tests based on action type:
   - Composite with shell logic: `src/*.sh` plus `test/*.bats` (see semver-validation pattern)
   - Go: `*_test.go` files (see linear-pr-commenter pattern)
   - Composite YAML-only: no unit tests, rely on actionlint
3. Add Makefile target: `test-<action-name>`
4. Add CI workflow: `.github/workflows/test-<action-name>.yaml` with path filter
5. Add README section under "Available Actions"

## Action pinning (non-negotiable)

All third-party actions use full SHA pins with version comment:

```yaml
# CORRECT
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

# WRONG - never use tag references
- uses: actions/checkout@v4
```

Standard pins used across the repo:
- `actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd` (v6.0.2)
- `actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020` (v4.4.0)
- `actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff` (v5.6.0)

Always add `persist-credentials: false` to checkout steps.

## Versioning and releasing

Each action gets its own tag (not repo-wide):

```bash
git tag -f <action-name>/v1
git push origin <action-name>/v1 --force
```

Advancing this tag IS the release. Callers pin the tag, not `main`, so a fix merged
to a shared action ships only when you move its tag to the merged commit. Skip this
and the fix strands on `main` while callers keep running the old code (pin drift:
DEVOPS-1126, DEVOPS-923).

`release-notification` uses the action-scoped `release-notification/v2` tag as a
coordination tag: the `notify-release.yaml` wrapper and its inner composite both
resolve at `@release-notification/v2`, so advancing that one tag moves them together.
(A legacy repo-wide `v1` tag predates this; callers no longer use it.)

Referenced as:
```yaml
uses: loft-sh/github-actions/.github/actions/<action-name>@<action-name>/v1
```

## CI workflow pattern for tests

```yaml
name: Test <action-name>
on:
  push:
    branches: [main]
    paths: ['.github/actions/<action-name>/**']
  pull_request:
    paths: ['.github/actions/<action-name>/**']
jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      # setup step + test command
```

## Gotchas: job-level `if` conditions

### success() transitive skip propagation

`success()` (the implicit default in `needs`) evaluates the ENTIRE transitive `needs` chain. If any ancestor job is skipped, ALL descendants skip too — even if the immediate parent succeeded.

Fix pattern: move skip-causing conditions from job-level `if` to step-level `if`. The job runs as a no-op (success) instead of being skipped, so downstream jobs proceed normally.

```yaml
# BAD: job skips on alpha tags, causing ALL downstream to skip
check_version:
  if: github.repository_owner == 'loft-sh' && !contains(github.event.release.tag_name, '-')

# GOOD: job always runs, steps skip on alpha tags
check_version:
  if: github.repository_owner == 'loft-sh'
  steps:
    - uses: actions/checkout@v6
      if: ${{ !contains(github.event.release.tag_name, '-') }}
    - name: Validate version
      if: ${{ !contains(github.event.release.tag_name, '-') }}
```

Reference: loft-enterprise#6265, vcluster#3636 (fix for vcluster#3593).

### Surgical condition removal

When fixing a compound `if` on a job, preserve all guards unrelated to the bug. Don't strip the whole condition — remove only the problematic part.

```yaml
# Original (buggy compound condition):
  if: github.repository_owner == 'loft-sh' && !contains(tag, '-')

# WRONG fix (removes ALL guards):
  if: always()

# CORRECT fix (preserves fork guard, moves only alpha condition to steps):
  if: github.repository_owner == 'loft-sh'
```

### Backport label matching

When creating a fix PR for a bug introduced by another PR, the fix must carry the same backport labels as the buggy PR — no more, no less. The fix only needs to reach branches where the bug was backported.

## Common mistakes

- Creating actions outside `.github/actions/` — all actions live there
- Forgetting path filter on test workflows — tests must be scoped
- Using `npm test` instead of `npx jest --ci` in Makefile — npm test may have different config
- Missing `permissions: contents: read` on CI jobs — principle of least privilege
- Forgetting to update README when adding new actions

