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
103 changes: 98 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
40 changes: 39 additions & 1 deletion .github/workflows/release-readiness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
{
Expand Down
69 changes: 55 additions & 14 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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")"
Comment thread
entelecheia marked this conversation as resolved.
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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -202,6 +241,7 @@ jobs:
publish-release:
needs: skill-bundle
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Publish the draft release
env:
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/release-readiness.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`에 의존하지 않음
Expand Down
4 changes: 3 additions & 1 deletion docs/release-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading