diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e256f7b..5cb2830 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,26 +1,49 @@ name: Verify on: pull_request: - branches: [dev] - push: - branches: [dev] + branches: [dev, main] permissions: contents: read concurrency: group: verify-${{ github.ref }} cancel-in-progress: true jobs: - packages: + verify: + name: verify runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + fetch-depth: 0 persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + - 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 verify passes, run the promote workflow for pull request #$PULL_REQUEST. Promotion publishes nothing; a version tag on main does." + } >> "$GITHUB_STEP_SUMMARY" + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 package-manager-cache: false - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.0 - name: Prepare pinned Rust toolchain diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml new file mode 100644 index 0000000..a8ba658 --- /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 verify + 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: verify + 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/release.yml b/.github/workflows/release.yml index eebae24..50aeb09 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,7 @@ name: Release # A version tag packages one VSIX, attaches it to a GitHub release and # publishes that same file to Open VSX and the Visual Studio Marketplace. +# Only a tag on promoted main whose commit passed verify publishes. # docs/releasing.md covers the one-time store setup and the Zed registry. on: push: @@ -13,9 +14,26 @@ concurrency: jobs: package: runs-on: ubuntu-24.04 + permissions: + checks: read + contents: read outputs: version: ${{ steps.version.outputs.version }} steps: + - name: Require a tag on promoted main that passed verify + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + fail() { echo "::error::$1"; exit 1; } + commit="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_REF_NAME" --jq .sha)" + status="$(gh api "repos/$GITHUB_REPOSITORY/compare/$commit...main" --jq .status)" + [[ "$status" == ahead || "$status" == identical ]] || + fail "$GITHUB_REF_NAME names $commit, which is not on main. Promote dev to main, then tag the main commit." + verify="$(gh api "repos/$GITHUB_REPOSITORY/commits/$commit/check-runs?check_name=verify&per_page=100" \ + --jq '[.check_runs[] | select(.app.slug == "github-actions")] | sort_by(.started_at) | last | .conclusion // "missing"')" + [[ "$verify" == success ]] || + fail "verify has not passed on $commit (newest result: $verify). Tag the main commit validated by its promotion pull request." - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/AGENTS.md b/AGENTS.md index 152acb9..e072a8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,9 @@ Read `docs/editor-contract.md` and the affected editor package before editing. download Rootform or trigger Terraform/provider/cloud execution. - Work on a branch and open a PR into `dev`. Never push directly to `main` or `dev`, force push, bypass checks or modify another task's branch/PR. +- `main` moves only by promotion: a PR from `dev` into `main` whose head + passed `verify`, fast-forwarded by `promote.yml`. Release tags name a + `main` commit; `docs/releasing.md` describes the release. Local development evidence stays outside the public repository. Do not commit private implementation, project paths, inputs, credentials or operator notes. diff --git a/docs/releasing.md b/docs/releasing.md index 030b5df..58a360e 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -9,21 +9,27 @@ accepts no pre-release suffix, and no store accepts the same version twice. 1. On a branch, set the new version in the three manifests, add an entry to `vscode/CHANGELOG.md` and open a pull request into `dev`. -2. After it merges, qualify the merge commit with the Rootform release it - targets: `bun run verify`, the VS Code extension-host suite with +2. After it merges, promote `dev`: open a pull request from `dev` into + `main`; once `verify` passes on its head, run + `gh workflow run promote.yml --ref dev -f pull_request=`, which + fast-forwards `main` to that exact commit. +3. Qualify the `main` commit with the Rootform release it targets: + `bun run verify`, the VS Code extension-host suite with `ROOTFORM_BINARY`, and the Zed package installed as a dev extension. -3. Tag the merge commit and push the tag: +4. Tag the `main` commit and push the tag: ```sh - git tag -a v0.1.0 -m "Rootform editors 0.1.0" + git tag -a v0.1.0 -m "Rootform editors 0.1.0" git push origin v0.1.0 ``` -4. Update the Zed registry, as described below. -5. Check each store page, then install from each store and open a `.rf.hcl` +5. Update the Zed registry, as described below. +6. Check each store page, then install from each store and open a `.rf.hcl` file with a released Rootform CLI. -The [release workflow](../.github/workflows/release.yml) checks that the tag +The [release workflow](../.github/workflows/release.yml) refuses a tag whose +commit is not on `main` or did not pass `verify` in its promotion pull +request. It checks that the tag matches the manifests, runs `verify`, packages one VSIX and attaches it with `SHA256SUMS` to the GitHub release. It publishes that same file to Open VSX and the Visual Studio Marketplace from the `release` environment, which only