diff --git a/docs/release/v1-strategy.md b/docs/release/v1-strategy.md new file mode 100644 index 0000000..9fe0032 --- /dev/null +++ b/docs/release/v1-strategy.md @@ -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** +`@ # 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 `@ # 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 + 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 `@ # 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 `@ # 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. + +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/* + 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 `@ # 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 + `@` → `@ # 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)