Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 260 additions & 0 deletions docs/release/v1-strategy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
---
title: muntianus/.github release strategy — v1.0.0 and beyond
audit_findings: F-002 (consumer @main migration → @sha hybrid)
status: design / proposed
authored: 2026-05-30
---

# `muntianus/.github` release strategy — v1.0.0 and beyond

## TL;DR

Consumers currently pin org-reusable workflows by raw commit SHA after
the F-002 sweep:

```yaml
uses: muntianus/.github/.github/workflows/reusable-deploy-vps.yml@deb9a4d... # main (post #27 SHA-pinning)
```

That defends supply-chain risk but is unreadable, ungroupable, and
upgrades are manual. Cutting a proper `v1.0.0` release plus a floating
`v1` major tag, and switching consumers to the **hybrid pin**
`@<sha> # v1.0.0`, gives us:

- Immutable execution (SHA wins at runtime — tag rewrites are
ineffective).
- Human-readable diffs (`# v1.0.0` says what the SHA *is*).
- Automatic upgrade PRs via Dependabot, which since
[dependabot-core PR #5951](https://github.com/dependabot/dependabot-core/pull/5951)
rewrites BOTH the SHA and the trailing tag comment.
- Group bumps so 5 consumer PRs become 1 reviewable batch.

## Strategy comparison

| Strategy | Immutable execution | Readable diff | Dependabot friendly | Tag-rewrite resistant |
|---|---|---|---|---|
| Raw SHA only (current) | yes | **no** | yes (rewrites SHA) | yes |
| `@v1` floating major | no | yes | yes | **no** |
| `@v1.0.0` exact tag | no | yes | yes | no |
| **Hybrid `@<sha> # v1.0.0`** | yes | yes | yes (PR #5951) | yes |

The "hybrid" column is the only one that's yes across the board. It
exists in the wild — Renovate has supported it for years, Dependabot
caught up in 2024. For an internal repo with 5 consumers and one
admin, this is the right answer.

## Why not just `@v1`

For an internal-only repo it's tempting — five consumers, one admin,
small blast radius. The trade is real: a malicious or buggy force-push
to `v1` immediately runs in every consumer's CI with their secrets
(`VPS_SSH_KEY`, `ANTHROPIC_API_KEY`). The
[`tj-actions/changed-files` March 2025 incident](https://www.stepsecurity.io/blog/pinning-github-actions-for-enhanced-security-a-complete-guide)
proved this is a real attack class, not theoretical: SHA-pinned
consumers survived; tag-pinned consumers ran the attacker's payload.

For workflows that handle secrets (`reusable-deploy-vps`,
`reusable-security-gate`), raw `@v1` is unacceptable. For
`openapi-sync` (no secrets, no privileged operations), it would be
acceptable — but mixing pin strategies across workflows in the same
repo creates a footgun where someone copy-pastes the `openapi-sync`
pattern onto `deploy-vps`. Hybrid pin every workflow uniformly and
avoid the discipline tax.

## What constitutes a "breaking change" for a reusable workflow

Reusable workflows are functions consumers call. The contract surface
is:

1. **Inputs** in `on.workflow_call.inputs` — adding required input,
removing input, changing type, narrowing accepted values
→ MAJOR.
2. **Secrets** in `on.workflow_call.secrets` — adding required
secret, removing secret, renaming → MAJOR.
3. **Outputs** in `on.workflow_call.outputs` — removing, renaming
→ MAJOR.
4. **Required permissions** in `permissions:` — broadening (consumer
must grant) → MAJOR. Narrowing → MINOR.
5. **Observable side effects** — artifacts emitted under a different
name, env vars set, files written, deploy targets changed →
MAJOR if relied upon.
6. **Behavioural changes that don't break the contract** but change
outcomes (e.g. tightening security checks, bumping Go) → MINOR.
7. **Pure bug fixes / docs / dependency SHA bumps** → PATCH.

Adopt [Conventional Commits](https://www.conventionalcommits.org/)
so release-please can infer the bump:

- `feat:` → MINOR
- `fix:` → PATCH
- `feat!:` / `BREAKING CHANGE:` footer → MAJOR

## Release toolchain

[`release-please`](https://github.com/googleapis/release-please) with
`release-type: simple` fits a workflows-only repo (no `package.json`,
no `pyproject.toml` to bump). It maintains `version.txt` +
`CHANGELOG.md` from Conventional Commits.

`.github/workflows/release-please.yml`:

```yaml
name: release-please
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@db8f2c60ee802b3748b512940dde88eabd7b7e01 # v4.4.0
Comment on lines +112 to +113
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The workflow steps do not clone the repository. Without a cloned repository, the runner's workspace is empty, and the subsequent git tag and git push commands in the Move floating v1 tag step will fail with a not a git repository error.

Please add the actions/checkout step at the beginning of the job, using a SHA-pinned reference to adhere to the repository's security guidelines.

Suggested change
steps:
- uses: googleapis/release-please-action@db8f2c60ee802b3748b512940dde88eabd7b7e01 # v4.4.0
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: googleapis/release-please-action@db8f2c60ee802b3748b512940dde88eabd7b7e01 # v4.4.0

id: release
with:
release-type: simple
# generates: version.txt, CHANGELOG.md, GitHub Release
- if: ${{ steps.release.outputs.release_created }}
name: Move floating v1 tag
run: |
git tag -f v1 "${{ steps.release.outputs.tag_name }}"
git push -f origin v1
```

The `v1` tag move runs only on a release commit, not on every push.
After `v1.0.0` ships, every subsequent `v1.X.Y` release advances `v1`
in lockstep. Consumers on `@<sha> # v1.0.0` are unaffected by the
floating tag — they upgrade through Dependabot, which sees the new
release on the Releases API and proposes a new pinned SHA + comment.

When we cut `v2.0.0`, `v1` stops moving forward; consumers still on
`v1.X.Y` keep working. New consumers can opt into `@<sha> # v2` or
stay on `v1` until ready.

## Tag immutability — rulesets

GitHub's legacy "tag protection rules" are deprecated. Use rulesets:

`Settings → Rules → Rulesets → New ruleset`:

- Name: `protect-version-tags`
- Enforcement status: Active
- Target: Tags → name pattern `v*`
- Rules:
- Restrict creations (allow only via release-please)
- Restrict updates
- Restrict deletions
- Bypass list: repository admins only, with audit logging.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since the release-please workflow runs automatically in CI and needs to force-push/update the floating v1 tag (which matches the v* pattern), restricting the bypass list to repository admins only will cause the automated workflow to fail when attempting to push the tag.

You should update the bypass list to also allow the GitHub Actions bypass actor (or the specific integration/app used for the workflow) to bypass the ruleset for tag updates.

Suggested change
- Bypass list: repository admins only, with audit logging.
- Bypass list: repository admins and GitHub Actions (for tag updates), with audit logging.


This makes a malicious `git push -f origin v1.0.0` from a compromised
collaborator account fail. A compromised admin account is still
game-over for the repo, but that's defended by the existing 2FA +
SSH-key audit posture.

## Dependabot config — one file per consumer

`.github/dependabot.yml` in each of the 5 consumer repos
(polymarket-arb, launchgate, benchmark-apps, architecture,
chaos-scenarios):

```yaml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
groups:
muntianus-workflows:
patterns:
- muntianus/.github/*
Comment on lines +171 to +172
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Dependabot, the dependency name for a reusable workflow is the repository name (e.g., muntianus/.github), not the full path to the workflow file. Therefore, the pattern muntianus/.github/* will not match the dependency name because of the trailing slash and wildcard.

To have the group correctly match and group updates for these workflows, the pattern should be updated to muntianus/.github.

Suggested change
patterns:
- muntianus/.github/*
patterns:
- muntianus/.github

commit-message:
prefix: ci
include: scope
labels:
- dependencies
- github-actions
open-pull-requests-limit: 5
```

The `groups` block collapses all `muntianus/.github/*` bumps into one
PR per week, so reviewers don't drown in five identical bumps.
`commit-message.prefix: ci` keeps the merged history clean —
Conventional-Commits-shaped, doesn't trigger a release-please bump on
the consumer.

Dependabot reads the consumer's existing `@<sha> # v1.0.0` lines and,
on a release, rewrites both:

```diff
- uses: muntianus/.github/.github/workflows/reusable-ci-go.yml@deb9a4da82fa78369ab44819a6fbfa12d231f064 # v1.0.0
+ uses: muntianus/.github/.github/workflows/reusable-ci-go.yml@2a1b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b # v1.1.0
```

If the comment is malformed, Dependabot falls back to bumping just
the SHA, which is degraded but not broken.

## One-shot rollout checklist

1. **Adopt Conventional Commits** for this repo (one-line update to
`AGENTS.md`).
2. **Land `release-please.yml`** workflow (above).
3. **Tag `v1.0.0` manually for the first release** — `git tag
v1.0.0; git push origin v1.0.0`. Future releases come from
release-please automatically.
4. **Hand-author `CHANGELOG.md`** for v1.0.0 describing each
workflow's `inputs`/`secrets`/`outputs` contract — this is the
reference downstream consumers cite when judging "is this bump
safe for me?".
5. **Push floating `v1`** — `git tag -f v1 v1.0.0; git push -f
origin v1`.
6. **Configure ruleset** on `v*` per the previous section.
7. **PR each of the 5 consumer repos** swapping
`@<sha>` → `@<sha> # v1.0.0` and dropping the
`# main (post #27 SHA-pinning)` comments.
8. **Enable Dependabot** on each consumer per the config above.

## Ongoing process

- Land changes on this repo via Conventional-Commits PRs.
- `release-please` opens a release PR with computed version + draft
CHANGELOG. Reviewers can hand-edit before merging.
- Merging the release PR cuts the tag, the GitHub Release, and
re-points `v1`.
- Dependabot picks up the new release within 7 days and fans out
hybrid-pin bumps to consumers.
- Consumers review the grouped PR (typically one of 5 line edits +
a CHANGELOG link) and merge. Their next CI run is on the new SHA.

## Honest critique — when SHA-only stays the right answer

For workflows that touch secrets we *don't* control (third-party
actions like `actions/checkout`), SHA-pin always — the upstream
maintainer's `v4` can change under us, and we have no operational
relationship with them.

For our own org-internal workflows the hybrid pin is right because we
control both ends of the supply chain: a malicious `v1.0.0` rewrite
requires admin-level repo compromise (rulesets) AND a 7-day window
where Dependabot eventually picks up the rewrite. Detection is high,
attack window is narrow, and the readability + automation wins are
real.

For a one-consumer repo, raw SHA without ceremony is fine — the
upgrade volume doesn't justify the toolchain. The break-even point
is around 3+ consumers and weekly upstream activity.

## References

- [GitHub Secure-use docs — pinning](https://docs.github.com/en/actions/reference/security/secure-use) (full-length SHA is the only immutable form)
- [Well-Architected — Securing GHA](https://wellarchitected.github.com/library/application-security/recommendations/actions-security/) (org-level guidance)
- [StepSecurity — pinning for security](https://www.stepsecurity.io/blog/pinning-github-actions-for-enhanced-security-a-complete-guide) (tj-actions incident analysis)
- [GitHub changelog Aug-2025 — SHA-pinning policy enforcement](https://github.blog/changelog/2025-08-15-github-actions-policy-now-supports-blocking-and-sha-pinning-actions/) (org-wide enforcement option)
- [Community discussion #30049 — reusable workflow semver](https://github.com/orgs/community/discussions/30049) (no official semver policy yet)
- [Dependabot github-actions ecosystem](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file) (config reference)
- [dependabot-core PR #5951 — hybrid pin support](https://github.com/dependabot/dependabot-core/pull/5951) (rewrites trailing tag comment)
- [Available rules for rulesets](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets) (tag rulesets — legacy protection deprecated)
- [release-please](https://github.com/googleapis/release-please) (`release-type: simple` is the right shape)
- [Xebia — How GitHub Actions versioning works](https://xebia.com/blog/how-github-actions-versioning-system-works/) (semver tradeoffs in practice)