From 420421967abd225e2e014bcd7fbaa9c956af3bdd Mon Sep 17 00:00:00 2001 From: Thierno Bah Date: Thu, 8 Oct 2026 01:08:55 +0200 Subject: [PATCH] ci(release): release promoted main on dispatch and move major tags forward --- .github/workflows/ci.yml | 36 ++++-- .github/workflows/promote.yml | 108 ++++++++++++++++++ .github/workflows/publish-v1.yml | 42 ------- .../published-release-integration.yml | 1 + .github/workflows/release.yml | 88 +++++++++++++- .releaserc.json | 8 +- AGENTS.md | 10 +- CONTRIBUTING.md | 27 +++-- docs/adr/001-release-automation.md | 2 +- docs/adr/006-github-native-actions.md | 16 +-- .../007-promoted-releases-and-major-tags.md | 104 +++++++++++++++++ docs/engineering/quality-gates.md | 7 +- docs/engineering/toolchain-policy.md | 9 +- scripts/validate-foundation.test.ts | 44 +++++-- scripts/validate-foundation.ts | 38 +++++- scripts/verify.test.ts | 6 +- 16 files changed, 439 insertions(+), 107 deletions(-) create mode 100644 .github/workflows/promote.yml delete mode 100644 .github/workflows/publish-v1.yml create mode 100644 docs/adr/007-promoted-releases-and-major-tags.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2484a9..f5998bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,10 +5,6 @@ on: branches: - dev - main - push: - branches: - - dev - - main permissions: contents: read @@ -30,6 +26,30 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Require promotion to fast-forward main from dev + if: github.base_ref == 'main' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PROMOTION_SOURCE: ${{ github.event.pull_request.head.repo.full_name }}:${{ github.head_ref }} + PULL_REQUEST: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + if [[ "$PROMOTION_SOURCE" != "$GITHUB_REPOSITORY:dev" ]]; then + echo "::error::main receives only promotions from $GITHUB_REPOSITORY:dev. Target dev instead." + exit 1 + fi + if ! git merge-base --is-ancestor "$BASE_SHA" "$HEAD_SHA"; then + echo "::error::main is not an ancestor of dev. Promotion must be a fast-forward." + exit 1 + fi + { + echo "## Promotion" + echo + echo "Fast-forwards main from \`$BASE_SHA\` to the exact dev commit \`$HEAD_SHA\` ($(git rev-list --count "$BASE_SHA..$HEAD_SHA") commits)." + echo "Once quality passes, run the promote workflow for pull request #$PULL_REQUEST. Promotion publishes no release." + } >> "$GITHUB_STEP_SUMMARY" + - name: Install exact Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: @@ -74,7 +94,8 @@ jobs: run: bun scripts/check-pr-metadata.ts - name: Validate pull request commits - if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' + # Commits are gated where they enter dev; a promotion carries those same commits. + if: github.base_ref == 'dev' && github.actor != 'dependabot[bot]' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} @@ -82,6 +103,7 @@ jobs: - name: Run full quality gate env: - ROOTFORM_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} - ROOTFORM_HEAD_BRANCH: ${{ github.event_name == 'pull_request' && github.head_ref || '' }} + # The product spec gate applies where changes enter dev; promotions carry gated changes. + ROOTFORM_BASE_SHA: ${{ github.base_ref == 'dev' && github.event.pull_request.base.sha || '' }} + ROOTFORM_HEAD_BRANCH: ${{ github.base_ref == 'dev' && github.head_ref || '' }} run: bun run verify diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml new file mode 100644 index 0000000..9010295 --- /dev/null +++ b/.github/workflows/promote.yml @@ -0,0 +1,108 @@ +name: promote + +on: + workflow_dispatch: + inputs: + pull_request: + description: Open pull request from dev into main whose exact head passed quality + required: true + type: number + +permissions: + contents: read + +concurrency: + group: promote-main + cancel-in-progress: false + +jobs: + promote: + name: Fast-forward main to validated dev + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + actions: read + contents: write + pull-requests: read + steps: + - name: Verify exact validated promotion + id: promotion + env: + GH_TOKEN: ${{ github.token }} + PULL_REQUEST: ${{ inputs.pull_request }} + REQUIRED_JOB: quality + run: | + set -euo pipefail + fail() { echo "::error::$1"; exit 1; } + [[ "$GITHUB_REF" == refs/heads/dev ]] || fail "Run promote from dev: gh workflow run promote.yml --ref dev -f pull_request=$PULL_REQUEST." + pr="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PULL_REQUEST")" + jq -e --arg repo "$GITHUB_REPOSITORY" \ + '.state == "open" and .base.ref == "main" and .head.ref == "dev" and .head.repo.full_name == $repo' \ + <<<"$pr" >/dev/null || fail "Pull request #$PULL_REQUEST is not an open promotion from dev into main." + head="$(jq -r .head.sha <<<"$pr")" + dev="$(gh api "repos/$GITHUB_REPOSITORY/branches/dev" --jq .commit.sha)" + main="$(gh api "repos/$GITHUB_REPOSITORY/branches/main" --jq .commit.sha)" + [[ "$head" == "$dev" ]] || fail "dev moved to $dev after pull request head $head. Wait for checks on the new head." + status="$(gh api "repos/$GITHUB_REPOSITORY/compare/$main...$head" --jq .status)" + [[ "$status" == ahead ]] || fail "main $main cannot fast-forward to $head (comparison: $status)." + # The newest run of this promotion on the exact head whose required job ran decides. + runs="$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?event=pull_request&head_sha=$head&per_page=100" | + jq -r --argjson pr "$PULL_REQUEST" \ + '[.workflow_runs[] | select(any(.pull_requests[]; .number == $pr and .base.ref == "main"))] | sort_by(-.run_number) | .[].id')" + verdict=none + url=none + for id in $runs; do + job="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$id/jobs?per_page=100" | + jq -c --arg name "$REQUIRED_JOB" '[.jobs[] | select(.name == $name and .conclusion != "skipped")] | last // empty')" + [[ -n "$job" ]] || continue + verdict="$(jq -r '.status + "/" + (.conclusion // "pending")' <<<"$job")" + url="$(jq -r .html_url <<<"$job")" + break + done + [[ "$verdict" == completed/success ]] || + fail "$REQUIRED_JOB has not passed for pull request #$PULL_REQUEST on exact head $head (newest result: $verdict, $url)." + { + echo "head=$head" + echo "main=$main" + echo "run=$url" + } >> "$GITHUB_OUTPUT" + + - name: Check out exact promotion history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + ref: ${{ steps.promotion.outputs.head }} + + - name: Fast-forward main to the validated commit + env: + HEAD_SHA: ${{ steps.promotion.outputs.head }} + # A plain push refuses anything but a fast-forward. + run: git push origin "$HEAD_SHA:refs/heads/main" + + - name: Confirm promotion + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ steps.promotion.outputs.head }} + PREVIOUS_MAIN: ${{ steps.promotion.outputs.main }} + PULL_REQUEST: ${{ inputs.pull_request }} + VALIDATION_RUN: ${{ steps.promotion.outputs.run }} + run: | + set -euo pipefail + main="$(gh api "repos/$GITHUB_REPOSITORY/branches/main" --jq .commit.sha)" + if [[ "$main" != "$HEAD_SHA" ]]; then + echo "::error::main is $main, expected $HEAD_SHA." + exit 1 + fi + merged=false + for _ in 1 2 3 4 5 6; do + merged="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PULL_REQUEST" --jq .merged)" + [[ "$merged" != true ]] || break + sleep 5 + done + [[ "$merged" == true ]] || echo "::warning::GitHub has not yet marked pull request #$PULL_REQUEST as merged." + { + echo "## Promotion" + echo + echo "main moved from \`$PREVIOUS_MAIN\` to \`$HEAD_SHA\`, the exact head validated by $VALIDATION_RUN." + echo "Pull request #$PULL_REQUEST merged: $merged." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/publish-v1.yml b/.github/workflows/publish-v1.yml deleted file mode 100644 index 69f462d..0000000 --- a/.github/workflows/publish-v1.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: publish first v1 ref - -on: - workflow_dispatch: - inputs: - expected-sha: - description: Exact reviewed and publicly qualified dev merge commit - required: true - type: string - -permissions: - contents: read - -concurrency: - group: publish-first-v1 - cancel-in-progress: false - -jobs: - publish: - runs-on: ubuntu-24.04 - timeout-minutes: 2 - permissions: - contents: write - actions: read - pull-requests: read - steps: - - name: Create first ref from reviewed green dev, never rewrite a ref - env: - GH_TOKEN: ${{ github.token }} - EXPECTED_SHA: ${{ inputs.expected-sha }} - run: | - set -euo pipefail - test "$GITHUB_REF" = refs/heads/dev - [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] - test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/dev" --jq .object.sha)" = "$EXPECTED_SHA" - test "$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?head_sha=$EXPECTED_SHA&per_page=1" --jq '.workflow_runs[0].conclusion')" = success - test -n "$(gh api "repos/$GITHUB_REPOSITORY/commits/$EXPECTED_SHA/pulls" --jq '.[] | select(.merged_at != null and .base.ref == "dev") | .number')" - if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/v1" > /dev/null 2>&1; then - echo 'v1 already exists; this workflow never updates published refs.' >&2 - exit 1 - fi - gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" -f ref=refs/tags/v1 -f "sha=$EXPECTED_SHA" --jq '{ref, sha: .object.sha}' diff --git a/.github/workflows/published-release-integration.yml b/.github/workflows/published-release-integration.yml index 75eebd9..efb98f4 100644 --- a/.github/workflows/published-release-integration.yml +++ b/.github/workflows/published-release-integration.yml @@ -1,4 +1,5 @@ name: published release integration +run-name: Install published Rootform ${{ inputs.version }} on: workflow_dispatch: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15f2ea8..1de8759 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,16 +1,14 @@ name: release +run-name: Release the Action from main on: - push: - branches: - - main workflow_dispatch: permissions: contents: read concurrency: - group: release-${{ github.ref }} + group: release cancel-in-progress: false jobs: @@ -19,9 +17,32 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: + actions: read + checks: read contents: write steps: + - name: Require promoted main qualified against a stable Rootform release + id: qualification + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + fail() { echo "::error::$1"; exit 1; } + [[ "$GITHUB_REF" == refs/heads/main ]] || fail "Release the Action from main: gh workflow run release.yml --ref main." + main="$(gh api "repos/$GITHUB_REPOSITORY/branches/main" --jq .commit.sha)" + [[ "$main" == "$GITHUB_SHA" ]] || fail "main moved to $main after this run started. Dispatch again." + quality="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/check-runs?check_name=quality&per_page=100" \ + --jq '[.check_runs[] | select(.app.slug == "github-actions")] | sort_by(.started_at) | last | .conclusion // "missing"')" + [[ "$quality" == success ]] || + fail "quality has not passed on $GITHUB_SHA (newest result: $quality). main changes only through a promotion pull request." + integration="$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/published-release-integration.yml/runs?head_sha=$GITHUB_SHA&status=success&per_page=100" \ + --jq '[.workflow_runs[] | select(.display_title | test("^Install published Rootform v?[0-9]+[.][0-9]+[.][0-9]+$"))] | first | .html_url // empty')" + [[ -n "$integration" ]] || + fail "No successful published release integration against a stable Rootform release on $GITHUB_SHA. Run gh workflow run published-release-integration.yml --ref main -f version= first." + echo "integration=$integration" >> "$GITHUB_OUTPUT" + echo "main at \`$GITHUB_SHA\` passed quality and $integration." >> "$GITHUB_STEP_SUMMARY" + - name: Check out full history uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -45,3 +66,62 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: node ./node_modules/semantic-release/bin/semantic-release.js + + - name: Confirm the release of this commit + id: release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + fail() { echo "::error::$1"; exit 1; } + tag="$(git tag --points-at "$GITHUB_SHA" --list 'v*.*.*' | sort -V | tail -n 1)" + if [[ -z "$tag" ]]; then + echo "No feat, fix or breaking change since the previous release: nothing was published." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + if ! release="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" 2>/dev/null)"; then + # semantic-release pushes the tag before it publishes the release; finish an interrupted run. + gh release create "$tag" --repo "$GITHUB_REPOSITORY" --verify-tag --generate-notes --title "$tag" + release="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag")" + fi + jq -e '.draft == false' <<<"$release" >/dev/null || fail "$tag is still a draft." + [[ "$(gh api "repos/$GITHUB_REPOSITORY/commits/$tag" --jq .sha)" == "$GITHUB_SHA" ]] || fail "$tag does not name $GITHUB_SHA." + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "Published $(jq -r .html_url <<<"$release") at \`$GITHUB_SHA\` (immutable: $(jq -r '.immutable // false' <<<"$release"))." >> "$GITHUB_STEP_SUMMARY" + + - name: Point the major tag at this release + if: steps.release.outputs.tag != '' + env: + GH_TOKEN: ${{ github.token }} + INTEGRATION: ${{ steps.qualification.outputs.integration }} + RELEASE: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + fail() { echo "::error::$1"; exit 1; } + if [[ ! "$RELEASE" =~ ^v([1-9][0-9]*)[.][0-9]+[.][0-9]+$ ]]; then + echo "$RELEASE is a 0.x or prerelease version: no major tag names it." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + major="v${BASH_REMATCH[1]}" + current='' + if ref="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$major" 2>/dev/null)"; then + current="$(jq -r .object.sha <<<"$ref")" + if [[ "$(jq -r .object.type <<<"$ref")" == tag ]]; then + current="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$current" --jq .object.sha)" + fi + fi + if [[ "$current" == "$GITHUB_SHA" ]]; then + echo "$major already names $RELEASE." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + if [[ -n "$current" ]]; then + status="$(gh api "repos/$GITHUB_REPOSITORY/compare/$current...$GITHUB_SHA" --jq .status)" + [[ "$status" == ahead ]] || + fail "$major names $current, which $RELEASE does not descend from (comparison: $status). A major tag only moves forward." + gh api -X PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/$major" -f sha="$GITHUB_SHA" -F force=true >/dev/null + else + gh api -X POST "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$major" -f sha="$GITHUB_SHA" >/dev/null + fi + [[ "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$major" --jq .object.sha)" == "$GITHUB_SHA" ]] || + fail "$major does not name $GITHUB_SHA after the update." + echo "$major now names $RELEASE at \`$GITHUB_SHA\` (previously \`${current:-absent}\`), qualified by $INTEGRATION." >> "$GITHUB_STEP_SUMMARY" diff --git a/.releaserc.json b/.releaserc.json index 1125323..a418441 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -2,13 +2,7 @@ "branches": ["main"], "tagFormat": "v${version}", "plugins": [ - [ - "@semantic-release/commit-analyzer", - { - "preset": "conventionalcommits", - "releaseRules": [{ "breaking": true, "release": "minor" }] - } - ], + ["@semantic-release/commit-analyzer", { "preset": "conventionalcommits" }], [ "@semantic-release/release-notes-generator", { diff --git a/AGENTS.md b/AGENTS.md index d9c6c9b..e885092 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,6 +135,9 @@ unavailable, report the exact missing proof; do not substitute confidence. - Branch from `dev` using `type/NNN-short-kebab-slug` for product specs and `type/short-kebab-slug` for repository-only work. - Open pull requests into `dev`; use squash merge. +- `main` receives only promotions: a pull request from `dev` into `main`, + validated by `quality` on its exact head and fast-forwarded by + `promote.yml`. Hotfixes go through `dev` and a promotion. - Use Conventional Commits: `type(scope): imperative summary`. - Keep subject at most 100 characters, lower-case, and without trailing period. - Do not push directly to `main` or `dev`. @@ -146,8 +149,11 @@ unavailable, report the exact missing proof; do not substitute confidence. ## Versioning -Published versions and major tags are owner decisions. Automation follows the -accepted release contract and never moves an existing tag. +Publishing is an owner decision: `release.yml` runs only when dispatched on +`main`, after `quality` and a stable published release integration on that +commit. semantic-release maps `fix`, `feat` and breaking changes to patch, +minor and major. The same run moves the major tag forward to the new release; +version tags never move. See `docs/adr/007-promoted-releases-and-major-tags.md`. ## Definition of done diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96f5d7a..b63a410 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,15 +34,24 @@ change. ## How a release happens -Merging into `dev` publishes nothing. Merging `dev` into `main` runs -`.github/workflows/release.yml`: semantic-release reads the Conventional Commit -history, computes the next version, then creates the Git tag and the GitHub -Release together. A merge carrying only `chore`, `ci`, `docs`, `style`, `test`, -or `refactor` commits produces no release, which is intended. - -While the action is `0.x`, a breaking change raises the minor version. It never -publishes `1.0.0`; that promotion is an owner decision documented in -`docs/adr/001-release-automation.md`. +Merging into `dev` publishes nothing, and neither does promotion. A promotion +is a pull request from `dev` into `main`; once `quality` passes on its exact +head, a maintainer runs `gh workflow run promote.yml --ref dev -f pull_request=`, +which fast-forwards `main` to that commit. + +A release is a separate maintainer dispatch on `main`: + +1. `gh workflow run published-release-integration.yml --ref main -f version=` + proves the promoted commit installs a published Rootform release anonymously. +2. `gh workflow run release.yml --ref main` checks that proof and `quality` on + the same commit, then semantic-release computes the version from the + Conventional Commit history and creates the tag and the GitHub Release + together. `fix` is a patch, `feat` a minor and a breaking change a new + major version. A promotion carrying only `chore`, `ci`, `docs`, `style`, + `test`, or `refactor` commits releases nothing. +3. The same run points the major tag (`v1` for `v1.x.y`) at the new release. + It only moves forward; rerunning resumes an interrupted release. See + `docs/adr/007-promoted-releases-and-major-tags.md`. Never create, move, or delete a tag or release by hand. Published references are what other people's workflows execute; correct a mistake with a new release. diff --git a/docs/adr/001-release-automation.md b/docs/adr/001-release-automation.md index 7aba9db..661c4ba 100644 --- a/docs/adr/001-release-automation.md +++ b/docs/adr/001-release-automation.md @@ -1,6 +1,6 @@ # ADR-001: Release automation for the action repository -- Status: Accepted +- Status: Accepted; superseded by ADR-007 for the release trigger, the breaking-change rule and major tags - Date: 2026-08-27 - Owners: @soulbah - Related spec: repository-only foundation work diff --git a/docs/adr/006-github-native-actions.md b/docs/adr/006-github-native-actions.md index 5c7f325..1c6152f 100644 --- a/docs/adr/006-github-native-actions.md +++ b/docs/adr/006-github-native-actions.md @@ -113,17 +113,11 @@ link. No conclusions or counts are reconstructed from JSON. ## Versioned Action refs -All six paths share the same Git reference. The first v1 ref must point to -reviewed merged qualified source; no existing tag moves. Immutable commit pins -remain the reproducible/security recommendation. Future major-alias updates -require a deliberate reviewed release decision; this task does not grant an -unrestricted tag-rewrite policy or publish another Rootform CLI release. -The dedicated `publish-v1.yml` maintainer dispatch bootstraps only this first -ref: it requires the exact current dev SHA, an associated merged dev PR and a -successful quality run at that SHA. It creates a new ref and refuses an -existing v1. Public consumer proof and lead review must be recorded before -dispatch. Historical semantic releases on main keep their existing contract; -this bootstrap does not change package or CLI semantic versions. +All six paths share the same Git reference. Immutable commit pins remain the +reproducible/security recommendation. ADR-007 replaces the original +`publish-v1.yml` bootstrap: `v1` names the newest published, qualified +`v1.x.y` release and moves only forward, inside the owner-dispatched release +run. Version tags never move. ## Sources diff --git a/docs/adr/007-promoted-releases-and-major-tags.md b/docs/adr/007-promoted-releases-and-major-tags.md new file mode 100644 index 0000000..9149dec --- /dev/null +++ b/docs/adr/007-promoted-releases-and-major-tags.md @@ -0,0 +1,104 @@ +# ADR-007: Promoted releases and major tags + +- Status: Accepted +- Date: 2026-10-07 +- Owners: @soulbah +- Owner approval: @soulbah — 2026-10-07 (delivery architecture direction: `main` moves only by promotion of validated `dev`, a breaking change is a new major version, and the major tag names a qualified compatible release) +- Related spec: repository-only release automation +- Supersedes: ADR-001 for the release trigger, the breaking-change rule and floating major tags; the `publish-v1.yml` bootstrap described in ADR-006 + +## Context + +ADR-001 published a release on every push to `main` and mapped a breaking +change to a minor bump so that automation could never reach `1.0.0`. ADR-006 +then created the `v1` ref once, through a dedicated bootstrap dispatch on a +`dev` commit, and forbade moving it. + +Three facts make that model wrong for a public Action: + +- README and migration guide tell consumers to use `rootform-dev/action@v1`. + `v1` names commit `17cf0cbe` on `dev`, which no release describes, and it + can never advance to a fix. +- GitHub's guidance for Actions is semantic version release tags, a major tag + moved to the newest compatible release, and a new major version for breaking + changes. Immutable releases protect each `vX.Y.Z` tag once published. +- Promoting `dev` into `main` must not publish anything by itself. A release + started by the same push that moved `main` runs after the fact and cannot + act as a gate. + +Since the seeded `v0.1.0` tag, `main`-bound history contains breaking commits +(the GitHub-native redesign and its predecessor), so the first computed release +under default rules is `v1.0.0`, which matches the public `@v1` contract. + +## Decision + +- `main` changes only through a pull request from `dev`. `quality` runs on + that exact head; `promote.yml`, dispatched from `dev` with the pull request + number, fast-forwards `main` to that commit and nothing else. No workflow + runs on push. `main` stays an ancestor of `dev`, so no back-merge exists; a + hotfix is a pull request into `dev` followed by a promotion. +- `release.yml` runs only on `workflow_dispatch` from `main`. Before + semantic-release runs, it requires that `main` still equals the dispatched + commit, that `quality` passed on it, and that `published-release-integration` + succeeded on it against a stable published Rootform release (its run name + records the version). +- semantic-release keeps the `conventionalcommits` defaults: `fix` is a + patch, `feat` a minor, a breaking change a major version. A promotion with + only `chore`, `ci`, `docs`, `refactor`, `style` or `test` commits + releases nothing. +- After publishing `vX.Y.Z` with X of 1 or more, the same run points the `vX` + tag at that commit. The tag only moves forward: the new commit must descend + from the commit `vX` names today, otherwise the run fails. A new major + creates its own tag; `v0` is never maintained. Version tags never move. +- A rerun resumes: if semantic-release pushed a tag without its release, the + run publishes the release for that tag, then moves the major tag. +- `publish-v1.yml` is removed. The existing `v1` advances to `v1.0.0` through + the first release run. +- Running `release.yml` remains an owner decision. + +## Alternatives considered + +- **Keep the push-triggered release.** Rejected: a promotion would publish + immediately, and the only proof would come from CI after publication. +- **Keep breaking changes as minor bumps.** Rejected: consumers of `@v1` would + receive breaking changes under the same major tag. +- **A separate workflow to move the major tag.** Rejected: the release is + qualified before it exists, so a second dispatch adds a manual step without + adding evidence. The release run carries both steps and resumes either. +- **release-please.** Rejected for the reasons in ADR-001: the Action + distributes Git refs and needs no version file. + +## Consequences + +- A promotion publishes nothing. A release is one owner dispatch after the + published release integration of the promoted commit. +- The first release is `v1.0.0`; `v1` then names a published, qualified + release and follows each compatible release. +- The Action cannot release before Rootform publishes a stable version, which + matches its contract of consuming only published Rootform releases. +- The release job needs `actions: read` and `checks: read` in addition to + `contents: write`. + +## Validation + +```bash +bun scripts/validate-foundation.ts +bun test scripts/validate-foundation.test.ts scripts/verify.test.ts +actionlint -no-color -oneline +``` + +The foundation validator fails when a release rule ships a breaking change as +anything but a major version, or when `release.yml` runs on any event other +than `workflow_dispatch`. + +## Reversal + +Restore a push trigger and custom release rules, and delete the major tag step. +Published releases and tags stay valid because nothing rewrites them. + +## Sources + +- https://docs.github.com/en/actions/how-tos/create-and-publish-actions/manage-custom-actions +- https://docs.github.com/en/actions/how-tos/create-and-publish-actions/using-immutable-releases-and-tags-to-manage-your-actions-releases +- https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases +- https://semantic-release.gitbook.io/semantic-release/support/faq diff --git a/docs/engineering/quality-gates.md b/docs/engineering/quality-gates.md index acfcb66..5533d31 100644 --- a/docs/engineering/quality-gates.md +++ b/docs/engineering/quality-gates.md @@ -13,9 +13,10 @@ Every durable rule needs an executable control and a testable completion conditi | Published bundle matches its source | `PreToolUse` blocks hand-edits to `dist/` | bundle rebuild and `git status --porcelain -- dist` in verify | Rebuilding from `src/` leaves `dist/` unchanged; a stale bundle fails the gate. | | Published entrypoint is runnable | action manifest validation | `bun scripts/validate-foundation.ts` | Each `action.yml` names a non-empty name and description, a pinned Node runtime, and an existing committed bundle. | | Agent cannot overwrite governed/generated files | `PreToolUse` blocks `LICENSE`, lockfiles, PRD, secrets, state, plans, build output | policy unit tests | Every deny fixture returns explicit reason; safe fixtures remain allowed. | -| Published tag or release is not moved by automation | `PreToolUse` blocks `git tag -f`, tag deletion, and `gh release` mutation | policy unit tests | Every consumer pinned to a major tag keeps the code it was reviewed against. | -| First v1 ref names reviewed green source | owner dispatch `publish-v1.yml`, exact dev/merged-PR/quality checks | workflow refusal checks, public consumer protocol in SPEC-006 evidence | First ref is created at the qualified merged SHA; an existing ref is never updated. | -| Release is derived from history, not chosen by hand | semantic-release on `main`; release plugins restricted | `bun scripts/validate-foundation.ts` | Release config targets only `main`, keeps `v${version}`, maps a breaking change to a minor bump, and declares no publishing or repository-writing plugin. | +| Published tag or release is not moved by an agent | `PreToolUse` blocks `git tag -f`, tag deletion, and `gh release` mutation | policy unit tests | Every consumer pinned to a version tag keeps the code it was reviewed against. | +| `main` holds only validated `dev` commits | `main` protection, promotion guard in `quality`, `promote.yml` fast-forward | `quality` on the `dev` → `main` pull request head | `main` equals a `dev` commit whose promotion pull request passed `quality`; no workflow runs on push. | +| Release is derived from history and qualified first | owner dispatch of `release.yml` on `main`; release plugins restricted | `bun scripts/validate-foundation.ts` | Release runs only on dispatch after `quality` and a stable published release integration on the exact commit; breaking changes are new major versions; no publishing or repository-writing plugin. | +| Major tag names a qualified compatible release | major tag step of `release.yml` | forward-only comparison before the update | `vX` names the newest published `vX.Y.Z` and never moves backwards or across majors. | | Workflow definition is statically valid | actionlint installed from a checksum-verified archive in CI | `actionlint` gate inside `bun run verify` | actionlint 1.7.12 exits `0` over `.github/workflows`. | | Destructive or bypass command is not automated | `PreToolUse` blocks destructive Git/filesystem/Terraform and `--no-verify` | policy unit tests | Deny matrix passes for every supported command form. | | Touched source uses the pinned formatter | `PostToolUse` invokes installed Biome | Biome check and format-hook tests | Hook never invokes `bunx`; formatter check exits `0`. | diff --git a/docs/engineering/toolchain-policy.md b/docs/engineering/toolchain-policy.md index 8865c79..81d1bf9 100644 --- a/docs/engineering/toolchain-policy.md +++ b/docs/engineering/toolchain-policy.md @@ -66,7 +66,8 @@ verified this way does not enter the gate. - Never run two dependency bots against the same ecosystem. - Security updates receive priority but still pass all gates. No blind auto-merge. - Review stable releases at least weekly during active development and before each release cut. -- Releases are produced only by `.github/workflows/release.yml` on `main`. - Automation never moves a published tag, never publishes to a registry, and - never pushes a commit into a protected branch. See - `docs/adr/001-release-automation.md`. +- Releases are produced only by `.github/workflows/release.yml`, dispatched on + `main`. Automation never moves a version tag, moves a major tag only forward + to a qualified release, never publishes to a registry, and never creates a + commit on a protected branch. See + `docs/adr/007-promoted-releases-and-major-tags.md`. diff --git a/scripts/validate-foundation.test.ts b/scripts/validate-foundation.test.ts index eed4315..802a19f 100644 --- a/scripts/validate-foundation.test.ts +++ b/scripts/validate-foundation.test.ts @@ -212,19 +212,22 @@ describe("foundation validation", () => { branches: ["main"], tagFormat: RELEASE_TAG_FORMAT, plugins: [ - [ - "@semantic-release/commit-analyzer", - { preset: "conventionalcommits", releaseRules: [{ breaking: true, release: "minor" }] }, - ], + ["@semantic-release/commit-analyzer", { preset: "conventionalcommits" }], ["@semantic-release/release-notes-generator", { preset: "conventionalcommits" }], ["@semantic-release/github", { successComment: false }], ], }; } - function releaseSandbox(config: Record = releaseConfig()): string { + const dispatchOnly = "name: release\non:\n workflow_dispatch:\njobs: {}\n"; + + function releaseSandbox( + config: Record = releaseConfig(), + workflow = dispatchOnly, + ): string { const dir = validSandbox(); writeJson(join(dir, ".releaserc.json"), config); + writeText(join(dir, ".github", "workflows", "release.yml"), workflow); return dir; } @@ -280,14 +283,39 @@ describe("foundation validation", () => { expect(joined).toContain("release plugin is missing: @semantic-release/github"); }); - test("rejects a breaking change that would publish a major release", async () => { + test("accepts a custom rule that keeps a breaking change major", async () => { + const config = releaseConfig(); + config.plugins = [ + [ + "@semantic-release/commit-analyzer", + { preset: "conventionalcommits", releaseRules: [{ breaking: true, release: "major" }] }, + ], + ["@semantic-release/github", {}], + ]; + expect(await validateReleaseConfiguration(releaseSandbox(config))).toEqual([]); + }); + + test("rejects a rule that would ship a breaking change under the current major", async () => { const config = releaseConfig(); config.plugins = [ - ["@semantic-release/commit-analyzer", { preset: "conventionalcommits" }], + [ + "@semantic-release/commit-analyzer", + { preset: "conventionalcommits", releaseRules: [{ breaking: true, release: "minor" }] }, + ], ["@semantic-release/github", {}], ]; expect((await validateReleaseConfiguration(releaseSandbox(config))).join("\n")).toContain( - "release rules must map a breaking change to a minor bump while the action is 0.x", + "release rules must publish a breaking change as a new major version", + ); + }); + + test("rejects a release workflow that publishes on push", async () => { + const workflow = + "name: release\non:\n push:\n branches: [main]\n workflow_dispatch:\njobs: {}\n"; + expect( + (await validateReleaseConfiguration(releaseSandbox(releaseConfig(), workflow))).join("\n"), + ).toContain( + "release workflow must run only on workflow_dispatch, got: push, workflow_dispatch", ); }); diff --git a/scripts/validate-foundation.ts b/scripts/validate-foundation.ts index b5edb62..52d68ee 100644 --- a/scripts/validate-foundation.ts +++ b/scripts/validate-foundation.ts @@ -216,18 +216,42 @@ export async function validateReleaseConfiguration(root: string): Promise Array.isArray(plugin) && plugin[0] === "@semantic-release/commit-analyzer", ); const analyzerOptions = Array.isArray(analyzer) ? analyzer[1] : undefined; const releaseRules = isPlainRecord(analyzerOptions) ? analyzerOptions.releaseRules : undefined; - const breakingRule = Array.isArray(releaseRules) - ? releaseRules.find((rule) => isPlainRecord(rule) && rule.breaking === true) + const weakerBreakingRule = Array.isArray(releaseRules) + ? releaseRules.find( + (rule) => isPlainRecord(rule) && rule.breaking === true && rule.release !== "major", + ) : undefined; - if (!isPlainRecord(breakingRule) || breakingRule.release !== "minor") - errors.push("release rules must map a breaking change to a minor bump while the action is 0.x"); + if (weakerBreakingRule !== undefined) + errors.push("release rules must publish a breaking change as a new major version"); + + /* main moves only by promotion; publishing is a separate, deliberate dispatch. */ + const workflowPath = join(root, ".github", "workflows", "release.yml"); + if (existsSync(workflowPath)) { + let workflow: unknown; + try { + workflow = Bun.YAML.parse(await Bun.file(workflowPath).text()); + } catch (error) { + errors.push( + `invalid release workflow: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const triggers = isPlainRecord(workflow) ? (workflow.on ?? workflow.true) : undefined; + const names = isPlainRecord(triggers) + ? Object.keys(triggers) + : Array.isArray(triggers) + ? triggers.map(String) + : [String(triggers)]; + if (names.length !== 1 || names[0] !== "workflow_dispatch") + errors.push(`release workflow must run only on workflow_dispatch, got: ${names.join(", ")}`); + } const manifestPath = join(root, "package.json"); if (existsSync(manifestPath)) { @@ -266,6 +290,7 @@ async function main(): Promise { ".github/dependabot.yml", ".github/pull_request_template.md", ".github/workflows/ci.yml", + ".github/workflows/promote.yml", ".github/workflows/published-release-integration.yml", ".github/workflows/release.yml", ".gitleaks.toml", @@ -296,6 +321,7 @@ async function main(): Promise { "compare/action.yml", "check/action.yml", "docs/adr/006-github-native-actions.md", + "docs/adr/007-promoted-releases-and-major-tags.md", "scripts/build.ts", "scripts/verify-dist.ts", "scripts/verify.ts", diff --git a/scripts/verify.test.ts b/scripts/verify.test.ts index 083b3b7..ea2da98 100644 --- a/scripts/verify.test.ts +++ b/scripts/verify.test.ts @@ -121,12 +121,12 @@ describe("bundle gate commands", () => { }); describe("CI product spec metadata", () => { - test("is supplied only while pull request branch identity still exists", async () => { + test("is supplied only where changes enter dev", async () => { const workflow = await Bun.file(join(repository, ".github/workflows/ci.yml")).text(); expect(workflow).toContain( - "github.event_name == 'pull_request' && github.event.pull_request.base.sha || ''", + "github.base_ref == 'dev' && github.event.pull_request.base.sha || ''", ); - expect(workflow).toContain("github.event_name == 'pull_request' && github.head_ref || ''"); + expect(workflow).toContain("github.base_ref == 'dev' && github.head_ref || ''"); expect(workflow).not.toContain("github.head_ref || github.ref_name"); });