From 000aabcbdc0ea785bcc1d94b3ab7b5197178a855 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Sun, 27 Sep 2026 19:53:24 +0900 Subject: [PATCH] ci: skip main re-verification of PR-verified trees; harden release workflow A main push whose tree a merged PR head already verified re-ran the full 3-OS matrix on an already green tree (25 of the last 30 main pushes). ci.yml gains a first `decision` job: run_full is false only when the push is the merge_commit_sha of a merged PR, the tree SHAs are equal, and the five gate check runs on the PR head are all completed/success (the same five names the release gates require, so one contract holds across the three files). Any API failure falls back to run_full=true with a warning. lint, pdf-parity and test are gated on it; the paths filter is unchanged. release.yml verify-ci and release-readiness.yml Gate ci-matrix accept that identical-tree PR head signal as a fallback when the target commit has no own green check runs, recording evidence that names the PR head. The readiness logic stays inline: Gate ci-matrix is a controller step and must not execute code from the evaluated checkout. release.yml also gets a workflow concurrency group (cancel-in-progress: false, so update-formula runs cannot race on Formula/hwp.rb) and timeout-minutes on all seven jobs. Refs #402 --- .github/workflows/ci.yml | 103 ++++++++++++++++++++++-- .github/workflows/release-readiness.yml | 40 ++++++++- .github/workflows/release.yml | 69 ++++++++++++---- docs/release-readiness.ko.md | 4 +- docs/release-readiness.md | 4 +- 5 files changed, 198 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d019958..cb55a0be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,18 +44,107 @@ env: # A new push on the same branch cancels the previous run (less waste, less latency). # main is never cancelled — per-commit CI signal on consecutive merges is the bisect basis -# (and the release gate reads it via the check-runs API). +# (and the release gate reads it via the check-runs API). A main push whose tree a merged PR +# head already verified runs only the `decision` job below: the deduplicated commit borrows +# the identical tree's PR signal, so the bisect basis is preserved in substance. concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} -# Gate layout: platform-independent checks run ONCE in `lint`; the 3-OS matrix in `test` -# runs only cargo test; the fixed Ubuntu PDF-parity job runs the committed public oracle. -# Same command set as scripts/check.sh (local mirror) plus the structured-corpus and public -# parity gates — keep them in sync or "local green, CI red" drift comes back. jobs: + # Skip re-verifying an already verified tree: a main push runs the full matrix unless the + # pushed commit is the merge_commit_sha of a merged PR, its tree SHA equals the PR head's + # tree SHA, and the five gate check runs on the PR head are all completed/success — the same + # five names release.yml's verify-ci and release-readiness.yml's Gate ci-matrix require, so + # one contract holds across the three files. Any API failure falls back to run_full=true + # with a warning (never silently skip). Direct pushes, multi-commit pushes, merges whose + # tree differs, and workflow_dispatch always run the full matrix. + decision: + name: decision + timeout-minutes: 10 + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + checks: read + outputs: + run_full: ${{ steps.dedupe.outputs.run_full }} + steps: + - name: Deduplicate an already verified main tree + id: dedupe + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -uo pipefail + run_full=true + reason="this event requires full validation" + expected=("lint" "pdf-parity" "test (ubuntu-latest)" "test (macos-latest)" "test (windows-latest)") + + if [ "$GITHUB_EVENT_NAME" = "push" ]; then + reason="no merged PR with an already verified identical tree was found" + + if ! pulls=$(gh api \ + -H "Accept: application/vnd.github+json" \ + "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls?per_page=100"); then + echo "::warning::Could not resolve associated pull requests; running full CI." + else + pr=$(jq -c --arg sha "$GITHUB_SHA" \ + '[.[] | select(.merged_at != null and .merge_commit_sha == $sha)] | sort_by(.number) | last // empty' \ + <<<"$pulls") + + if [ -n "$pr" ]; then + pr_number=$(jq -r .number <<<"$pr") + head_sha=$(jq -r .head.sha <<<"$pr") + + if push_tree=$(gh api "repos/$GITHUB_REPOSITORY/git/commits/$GITHUB_SHA" --jq .tree.sha) \ + && head_tree=$(gh api "repos/$GITHUB_REPOSITORY/git/commits/$head_sha" --jq .tree.sha) \ + && runs=$(gh api "repos/$GITHUB_REPOSITORY/commits/$head_sha/check-runs?filter=latest&per_page=100"); then + if [ "$push_tree" != "$head_tree" ]; then + reason="the merged tree differs from PR #$pr_number head $head_sha" + else + all_green=true + for name in "${expected[@]}"; do + state="$(jq -r --arg n "$name" \ + '[.check_runs[] | select(.name == $n)] | sort_by(.completed_at) | .[-1] | if . == null then "absent" else "\(.status)/\(.conclusion // "pending")" end' \ + <<<"$runs")" + echo "$name: $state" + [ "$state" = "completed/success" ] || all_green=false + done + if [ "$all_green" = "true" ]; then + run_full=false + reason="PR #$pr_number head $head_sha validated the identical tree $push_tree" + else + reason="the five gate check runs on PR #$pr_number head $head_sha are not all green" + fi + fi + else + echo "::warning::Could not verify the associated PR tree and check runs; running full CI." + fi + fi + fi + fi + + echo "run_full=$run_full" >> "$GITHUB_OUTPUT" + echo "CI decision: $reason" + { + if [ "$run_full" = "false" ]; then + echo "### CI deduplicated" + echo "$reason. The full matrix is skipped for this main push." + else + echo "### CI full run" + echo "$reason." + fi + } >> "$GITHUB_STEP_SUMMARY" + + # Gate layout: platform-independent checks run ONCE in `lint`; the 3-OS matrix in `test` + # runs only cargo test; the fixed Ubuntu PDF-parity job runs the committed public oracle. + # Same command set as scripts/check.sh (local mirror) plus the structured-corpus and public + # parity gates — keep them in sync or "local green, CI red" drift comes back. lint: name: lint + needs: decision + if: needs.decision.outputs.run_full == 'true' timeout-minutes: 30 runs-on: ubuntu-latest steps: @@ -144,6 +233,8 @@ jobs: pdf-parity: name: pdf-parity + needs: decision + if: needs.decision.outputs.run_full == 'true' timeout-minutes: 30 # The manifest pins the Poppler binary, so do not use ubuntu-latest here. runs-on: ubuntu-24.04 @@ -212,6 +303,8 @@ jobs: test: name: test (${{ matrix.os }}) + needs: decision + if: needs.decision.outputs.run_full == 'true' timeout-minutes: 30 strategy: fail-fast: false diff --git a/.github/workflows/release-readiness.yml b/.github/workflows/release-readiness.yml index 5f9de510..b05b8292 100644 --- a/.github/workflows/release-readiness.yml +++ b/.github/workflows/release-readiness.yml @@ -830,7 +830,10 @@ jobs: # Checklist line 12, read rather than re-run (D-07): the evaluated commit's own CI check # runs are the 3-OS evidence. The expected job names below must stay in sync with ci.yml, # exactly as release.yml's verify-ci job already requires. An absent or incomplete run is - # `pending` and fails the job; it is never a pass. Controller step: it queries the API, it + # `pending` and fails the job; it is never a pass. One exception: ci.yml deduplicates a + # main push whose tree a merged PR head already verified, so such a commit has no own + # check runs — the identical-tree fallback below accepts the PR head's green runs, with + # the evidence naming the PR head it relied on. Controller step: it queries the API, it # runs nothing from the evaluated ref. - name: Gate ci-matrix id: gate-ci-matrix @@ -877,6 +880,41 @@ jobs: evidence="check-runs API for $READINESS_SHA: all five ci.yml jobs are completed/success" else evidence="check-runs API for $READINESS_SHA: $problems" + # Identical-tree fallback (same rule as release.yml verify-ci): a main commit + # deduplicated by ci.yml's decision job has no own check runs; accept the merged + # PR head's green runs when the trees match. Inline, not a repository script: + # this is a controller step and must not execute code from the evaluated ref. + if pulls="$(gh api "repos/$GITHUB_REPOSITORY/commits/$READINESS_SHA/pulls?per_page=100")"; then + pr="$(jq -c --arg sha "$READINESS_SHA" \ + '[.[] | select(.merged_at != null and .merge_commit_sha == $sha)] | sort_by(.number) | last // empty' \ + <<<"$pulls")" + if [ -n "$pr" ]; then + pr_number="$(jq -r .number <<<"$pr")" + head_sha="$(jq -r .head.sha <<<"$pr")" + if evaluated_tree="$(gh api "repos/$GITHUB_REPOSITORY/git/commits/$READINESS_SHA" --jq .tree.sha)" \ + && head_tree="$(gh api "repos/$GITHUB_REPOSITORY/git/commits/$head_sha" --jq .tree.sha)" \ + && [ "$evaluated_tree" = "$head_tree" ] \ + && head_runs="$(gh api "repos/$GITHUB_REPOSITORY/commits/$head_sha/check-runs?filter=latest&per_page=100")"; then + head_green=true + head_problems="" + for name in "${expected[@]}"; do + state="$(jq -r --arg n "$name" \ + '[.check_runs[] | select(.name == $n)] | sort_by(.completed_at) | .[-1] | if . == null then "absent" else "\(.status)/\(.conclusion // "pending")" end' \ + <<<"$head_runs")" + echo "$name (PR #$pr_number head): $state" + [ "$state" = "completed/success" ] || { head_green=false; head_problems="${head_problems:+$head_problems, }$name=$state"; } + done + if [ "$head_green" = "true" ]; then + status=pass + evidence="check-runs API for $READINESS_SHA: PR #$pr_number head $head_sha validated identical tree $evaluated_tree; all five ci.yml jobs are completed/success on the PR head" + else + echo "PR #$pr_number head $head_sha shares the tree but is not green: $head_problems" + fi + fi + fi + else + echo "the pulls API call failed; keeping the own-check-runs outcome" >&2 + fi fi printf '%s\t%s\t%s\n' ci-matrix "$status" "$evidence" >>"$GATES_FILE" { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 103e68a4..caee3c52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,9 @@ name: Release # Pushing a vX.Y.Z tag publishes platform `hwp` binaries to a GitHub Release (archive + -# sha256) only after (1) the tagged commit already has a green CI run and (2) the tag +# sha256) only after (1) the tagged commit already has a green CI run — its own, or the green +# run of a merged PR head whose tree is identical (ci.yml deduplicates such main pushes, so +# the tagged commit can legitimately have no own check runs) — and (2) the tag # matches the Cargo.toml version. # Release flow (main is protected, so the version bump cannot be pushed to it directly): # close the CHANGELOG section and run scripts/release.sh X.Y.Z on a branch, discard the tag @@ -13,6 +15,14 @@ on: # Exact version verification is the verify-version job's job. tags: ["v*.*.*"] +# Tag releases run one at a time, so two update-formula runs cannot race on Formula/hwp.rb. +# GitHub keeps one pending run per group: a third tag pushed while one run is running and +# another is pending replaces the pending one; that cancelled tag is recovered by re-running +# its workflow (create-release is rerun-safe). +concurrency: + group: release + cancel-in-progress: false + permissions: contents: write pull-requests: write @@ -26,9 +36,12 @@ jobs: # Pass gate: the tagged commit must already have a green CI run (ci.yml runs on every # main push, and release.sh always tags main HEAD). Re-running the whole matrix here # duplicated ~25 runner-minutes per release; reading the check-runs API gives the same - # guarantee. The job names below must stay in sync with ci.yml. + # guarantee. ci.yml may deduplicate a main push whose tree a merged PR head already + # verified, leaving the commit with no own check runs — the identical-tree fallback below + # accepts that PR head's green runs. The job names below must stay in sync with ci.yml. verify-ci: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v7 with: @@ -41,19 +54,41 @@ jobs: run: | set -euo pipefail expected=("lint" "pdf-parity" "test (ubuntu-latest)" "test (macos-latest)" "test (windows-latest)") - runs="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/check-runs?filter=latest&per_page=100")" - failed=0 - for name in "${expected[@]}"; do - state="$(jq -r --arg n "$name" \ - '[.check_runs[] | select(.name == $n)] | sort_by(.completed_at) | .[-1] | if . == null then "absent" else "\(.status)/\(.conclusion // "pending")" end' \ - <<<"$runs")" - echo "$name: $state" - [ "$state" = "completed/success" ] || failed=1 - done - if [ "$failed" -ne 0 ]; then - echo "::error::Tagged commit $GITHUB_SHA has no green CI. Let the CI workflow pass on main, then re-run this workflow." - exit 1 + # Prints the five states for $1 and succeeds only if all are completed/success. + check_green() { + local sha="$1" runs state name failed=0 + runs="$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?filter=latest&per_page=100")" || return 1 + for name in "${expected[@]}"; do + state="$(jq -r --arg n "$name" \ + '[.check_runs[] | select(.name == $n)] | sort_by(.completed_at) | .[-1] | if . == null then "absent" else "\(.status)/\(.conclusion // "pending")" end' \ + <<<"$runs")" + echo "$name: $state" + [ "$state" = "completed/success" ] || failed=1 + done + return "$failed" + } + if check_green "$GITHUB_SHA"; then + echo "CI evidence: own check runs" + exit 0 fi + # Identical-tree fallback: a main commit deduplicated by ci.yml's decision job has + # no own check runs; accept the merged PR head's green runs when the trees match. + pulls="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls?per_page=100")" + pr="$(jq -c --arg sha "$GITHUB_SHA" \ + '[.[] | select(.merged_at != null and .merge_commit_sha == $sha)] | sort_by(.number) | last // empty' \ + <<<"$pulls")" + if [ -n "$pr" ]; then + pr_number="$(jq -r .number <<<"$pr")" + head_sha="$(jq -r .head.sha <<<"$pr")" + tag_tree="$(gh api "repos/$GITHUB_REPOSITORY/git/commits/$GITHUB_SHA" --jq .tree.sha)" + head_tree="$(gh api "repos/$GITHUB_REPOSITORY/git/commits/$head_sha" --jq .tree.sha)" + if [ "$tag_tree" = "$head_tree" ] && check_green "$head_sha"; then + echo "CI evidence: PR #$pr_number head $head_sha validated identical tree $tag_tree" + exit 0 + fi + fi + echo "::error::Tagged commit $GITHUB_SHA has no green CI and no identical-tree PR head with green CI. Let the CI workflow pass on main, then re-run this workflow." + exit 1 # Second half of the same gate: the release BODY must carry the marker-bounded # Verification block, and the release-readiness run it cites must be a successful run of @@ -70,6 +105,7 @@ jobs: # Version gate — the tag must match the Cargo.toml [workspace.package] version. verify-version: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@v7 - name: Tag matches Cargo.toml version @@ -89,6 +125,7 @@ jobs: create-release: needs: [verify-ci, verify-version] runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@v7 - name: Create GitHub Release @@ -107,6 +144,7 @@ jobs: # Build platform `hwp` binaries -> upload archives (tar.gz/zip) + sha256 to the release. upload-assets: needs: create-release + timeout-minutes: 30 strategy: fail-fast: false # one target's failure must not cancel the others matrix: @@ -162,6 +200,7 @@ jobs: skill-bundle: needs: upload-assets runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v7 - name: Assemble and upload hwp-skill-claude-web.zip @@ -202,6 +241,7 @@ jobs: publish-release: needs: skill-bundle runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Publish the draft release env: @@ -216,6 +256,7 @@ jobs: update-formula: needs: publish-release runs-on: ubuntu-latest + timeout-minutes: 15 steps: # Check out main, not the tag — the formula change targets main. - uses: actions/checkout@v7 diff --git a/docs/release-readiness.ko.md b/docs/release-readiness.ko.md index 44f91a52..b467f6f5 100644 --- a/docs/release-readiness.ko.md +++ b/docs/release-readiness.ko.md @@ -9,7 +9,9 @@ - [ ] `cargo clippy --workspace --all-targets -- -D warnings` - [ ] `cargo test --workspace` - [ ] `scripts/check-structured-corpus.sh` -- [ ] 로컬 소스 빌드 기준으로 Linux·macOS·Windows CI 매트릭스 전부 green +- [ ] 로컬 소스 빌드 기준으로 Linux·macOS·Windows CI 매트릭스 전부 green (green 신호는 커밋 + 자체의 check run이거나, ci.yml이 중복 제거한 main 푸시의 경우 트리가 동일한 병합 PR + head의 green run일 수 있음) - [ ] 코퍼스 manifest·run·artifact JSON이 고정 스키마를 통과 - [ ] manifest의 모든 pin과 `corpus/structured-v1/TRACKED_FILES.txt`가 Git 인덱스에 존재 - [ ] 코퍼스 잡이 시스템 폰트 설치나 `HWP_FONT_DIR`에 의존하지 않음 diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 5dddad66..e5a319bf 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -9,7 +9,9 @@ package upload or release publication. - [ ] `cargo clippy --workspace --all-targets -- -D warnings` - [ ] `cargo test --workspace` - [ ] `scripts/check-structured-corpus.sh` -- [ ] Linux, macOS and Windows CI matrix all green from local source builds +- [ ] Linux, macOS and Windows CI matrix all green from local source builds (the green signal + can be the commit's own check runs or, for a main push that ci.yml deduplicated, a merged + PR head's green runs on the identical tree) - [ ] corpus manifest/run/artifact JSON valid against their frozen schemas - [ ] all manifest pins and `corpus/structured-v1/TRACKED_FILES.txt` present in the Git index - [ ] no ambient font install or `HWP_FONT_DIR` dependency in the corpus job