Skip to content
Merged
Show file tree
Hide file tree
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
36 changes: 29 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ on:
branches:
- dev
- main
push:
branches:
- dev
- main

permissions:
contents: read
Expand All @@ -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:
Expand Down Expand Up @@ -74,14 +94,16 @@ 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 }}
run: bun scripts/check-commit-range.ts

- 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
108 changes: 108 additions & 0 deletions .github/workflows/promote.yml
Original file line number Diff line number Diff line change
@@ -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"
42 changes: 0 additions & 42 deletions .github/workflows/publish-v1.yml

This file was deleted.

1 change: 1 addition & 0 deletions .github/workflows/published-release-integration.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: published release integration
run-name: Install published Rootform ${{ inputs.version }}

on:
workflow_dispatch:
Expand Down
88 changes: 84 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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=<stable Rootform 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:
Expand All @@ -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"
8 changes: 1 addition & 7 deletions .releaserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand Down
10 changes: 8 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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

Expand Down
Loading
Loading