From 79dc6ab57ce4c1c2bc98aa8421420ebc6321a57d Mon Sep 17 00:00:00 2001 From: Sun-sunshine06 Date: Thu, 5 Nov 2026 00:35:50 +0800 Subject: [PATCH] fix(release): restore checked manifest PRs and native Intel gate --- .github/scripts/sync-release-manifests.sh | 41 +++++++++ .../scripts/sync-release-manifests.test.sh | 68 +++++++++++++++ .github/workflows/ci.yml | 3 + .../release-macos-x64-native-check.yml | 28 ++++-- .github/workflows/release.yml | 85 ++++++------------- packaging/README.md | 34 +++++++- 6 files changed, 194 insertions(+), 65 deletions(-) create mode 100644 .github/scripts/sync-release-manifests.sh create mode 100644 .github/scripts/sync-release-manifests.test.sh diff --git a/.github/scripts/sync-release-manifests.sh b/.github/scripts/sync-release-manifests.sh new file mode 100644 index 000000000..7360f2103 --- /dev/null +++ b/.github/scripts/sync-release-manifests.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +# update-shas.sh has already generated packaging/ from the published release. +: "${GH_TOKEN:?Set RELEASE_BOT_TOKEN with Contents and Pull requests write permissions}" +: "${RELEASE_TAG:?A stable release tag is required}" +if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo '::error::Manifest sync requires a stable vX.Y.Z release tag' + exit 1 +fi + +if git diff --quiet -- packaging/; then + echo '::notice::Packaging manifests already match the release' + exit 0 +fi + +branch="release/${RELEASE_TAG}-manifests" +# Capture the remote head before replacing our generated branch. A concurrent +# writer must cause a failure, not lose their changes to an unconditional force. +expected="$(git ls-remote --heads origin "refs/heads/$branch" | cut -f1)" +git config user.name 'github-actions[bot]' +git config user.email '41898282+github-actions[bot]@users.noreply.github.com' +git add -- packaging/ +git commit -m "chore(release): sync manifests to $RELEASE_TAG" +git push --force-with-lease="refs/heads/$branch:$expected" origin "HEAD:refs/heads/$branch" + +pr_number="$(gh pr list --base main --head "$branch" --state open --json number --jq '.[0].number // empty')" +if [ -z "$pr_number" ]; then + body_file="$(mktemp)" + trap 'rm -f "$body_file"' EXIT + printf 'Sync distribution manifests to %s using the published release checksums.\n\nGenerated by the release workflow. Required CI must pass before merging.\n' "$RELEASE_TAG" > "$body_file" + gh pr create --base main --head "$branch" \ + --title "chore(release): sync manifests to $RELEASE_TAG" --body-file "$body_file" + pr_number="$(gh pr list --base main --head "$branch" --state open --json number --jq '.[0].number // empty')" +fi +if [ -z "$pr_number" ]; then + echo '::error::Could not resolve the manifest PR after creation' + exit 1 +fi +gh pr merge "$pr_number" --auto --squash +echo "::notice::Manifest PR #$pr_number is awaiting required checks and merge" diff --git a/.github/scripts/sync-release-manifests.test.sh b/.github/scripts/sync-release-manifests.test.sh new file mode 100644 index 000000000..a10b71689 --- /dev/null +++ b/.github/scripts/sync-release-manifests.test.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +script="$(cd "$(dirname "$0")" && pwd)/sync-release-manifests.sh" +fixture="$(mktemp -d)" +trap 'rm -rf "$fixture"' EXIT +mkdir -p "$fixture/bin" +cat > "$fixture/bin/gh" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$GH_TEST_LOG" +case "$1 $2" in + 'pr list') [ ! -f "$GH_TEST_CREATED" ] || echo 123 ;; + 'pr create') + [ "${GH_TEST_DENY_CREATE:-0}" != 1 ] || exit 1 + touch "$GH_TEST_CREATED" + ;; + 'pr merge') [ "$3" = 123 ] ;; + *) exit 1 ;; +esac +MOCK +chmod +x "$fixture/bin/gh" +export PATH="$fixture/bin:$PATH" +export GH_TOKEN=test-token RELEASE_TAG=v1.2.3 +export GH_TEST_LOG="$fixture/gh.log" GH_TEST_CREATED="$fixture/created" +git init --bare -q "$fixture/remote.git" +git init -q -b main "$fixture/work" +cd "$fixture/work" +git config user.name Test +git config user.email test@example.invalid +mkdir packaging +echo old > packaging/manifest +git add . +git commit -qm initial +git remote add origin "$fixture/remote.git" +git push -q origin main +original_main="$(git rev-parse HEAD)" + +# A no-op neither creates a PR nor pushes a branch. +bash "$script" +test ! -e "$GH_TEST_LOG" + +# Missing credentials fail before touching the remote. +echo new > packaging/manifest +if GH_TOKEN= bash "$script"; then exit 1; fi +test -z "$(git ls-remote --heads origin refs/heads/release/v1.2.3-manifests)" + +# A real commit and remote branch, but only mocked GitHub API calls. +bash "$script" +test "$(git ls-remote --heads origin refs/heads/main | cut -f1)" = "$original_main" +git log -1 --format=%B | (! grep -F '[skip ci]') +grep -F 'pr create --base main --head release/v1.2.3-manifests' "$GH_TEST_LOG" +grep -F 'pr merge 123 --auto --squash' "$GH_TEST_LOG" + +# Rerunning from main updates the generated branch and reuses its open PR. +git reset --hard -q "$original_main" +echo newer > packaging/manifest +bash "$script" +test "$(grep -c '^pr create ' "$GH_TEST_LOG")" = 1 +test "$(git ls-remote --heads origin refs/heads/release/v1.2.3-manifests | cut -f1)" = "$(git rev-parse HEAD)" + +# A forbidden PR creation must fail, rather than reporting a green sync job. +git reset --hard -q "$original_main" +echo denied > packaging/manifest +rm "$GH_TEST_CREATED" +if GH_TEST_DENY_CREATE=1 bash "$script"; then exit 1; fi +test "$(git ls-remote --heads origin refs/heads/main | cut -f1)" = "$original_main" +echo 'Manifest sync integration checks passed' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cf9a380c..f5195a275 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,9 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Test manifest PR automation + run: bash .github/scripts/sync-release-manifests.test.sh + - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 diff --git a/.github/workflows/release-macos-x64-native-check.yml b/.github/workflows/release-macos-x64-native-check.yml index 47a599d5d..db02785aa 100644 --- a/.github/workflows/release-macos-x64-native-check.yml +++ b/.github/workflows/release-macos-x64-native-check.yml @@ -4,8 +4,21 @@ on: push: branches: - dev/v0.2 - tags: - - 'v*.*.*' + pull_request: + branches: [main, dev/v0.2] + paths: + - '.github/workflows/release-macos-x64-native-check.yml' + - '.github/workflows/release.yml' + - 'apps/desktop/electron-builder.yml' + - 'apps/desktop/scripts/**' + workflow_call: + inputs: + ref: + required: true + type: string + upload-artifact: + type: boolean + default: false workflow_dispatch: inputs: ref: @@ -14,7 +27,7 @@ on: type: string concurrency: - group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }} + group: native-intel-${{ github.workflow }}-${{ inputs.ref || github.ref }} cancel-in-progress: false permissions: @@ -23,11 +36,14 @@ permissions: jobs: macos-x64-native: name: Native Intel packaging check - runs-on: macos-13 + runs-on: macos-15-intel timeout-minutes: 60 env: - CHECK_REF: ${{ github.event_name == 'push' && github.ref || inputs.ref }} + CHECK_REF: ${{ inputs.ref || github.ref }} steps: + - name: Verify native Intel runner + run: test "$(uname -m)" = x86_64 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ env.CHECK_REF }} @@ -100,6 +116,8 @@ jobs: exit 1 - name: Upload native x64 artifact + # A called release check must not add a second DMG to publish's downloads. + if: github.workflow == 'Release macOS x64 native check' || inputs.upload-artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: native-check-macos-x64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ac3ce057..79e5ad394 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,10 +79,16 @@ jobs: - name: Test run: pnpm test - # ------------------------------------------------------------------ - # Changelog: build release notes from conventional commits. - # Runs in parallel with the gate; the build matrix waits for both. - # ------------------------------------------------------------------ + # Reuse the same native check exercised on packaging PRs. Publishing must + # wait for it; build artifacts can still be produced in parallel. + native-intel: + name: Validate native Intel release + needs: [gate] + uses: ./.github/workflows/release-macos-x64-native-check.yml + with: + ref: ${{ github.event_name == 'push' && github.ref || format('refs/tags/{0}', inputs.tag) }} + upload-artifact: false + changelog: name: Build changelog runs-on: ubuntu-latest @@ -146,8 +152,7 @@ jobs: fail-fast: false matrix: # macOS artifacts are both produced on the Apple Silicon runner. The - # supplemental macos-13 Intel workflow validates native packaging, but - # its queue no longer blocks publishing a release. + # native-intel reusable workflow gates publishing on a native smoke test. include: - os: macos-latest # arm64 Apple Silicon runner label: macos @@ -428,7 +433,7 @@ jobs: # ------------------------------------------------------------------ publish: name: Publish GitHub Release - needs: [build, changelog, provenance] + needs: [build, changelog, provenance, native-intel] runs-on: ubuntu-latest permissions: actions: read @@ -478,7 +483,7 @@ jobs: # Sync in-repo distribution manifests (cask / scoop / winget / flatpak) # to the freshly-published release. Pulls SHA256SUMS.txt from the # release, rewrites version strings + checksums + URLs across all four - # channels, and commits the diff back to main so the packaging/ tree + # channels, and opens a checked PR so the packaging/ tree # stays in sync without a manual step. # ------------------------------------------------------------------ bump-manifests: @@ -490,13 +495,21 @@ jobs: contents: write pull-requests: write steps: + - name: Require manifest PR token + env: + RELEASE_BOT_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} + run: | + if [ -z "$RELEASE_BOT_TOKEN" ]; then + echo "::error::Set RELEASE_BOT_TOKEN with Contents and Pull requests write permissions. GITHUB_TOKEN cannot trigger the required PR checks. See packaging/README.md." + exit 1 + fi + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - # Check out the default branch (not the tag) so the commit lands - # on main. We pass the version explicitly to the script. + # Generate the PR from main; never push directly to the protected branch. ref: main fetch-depth: 0 - token: ${{ secrets.RELEASE_BOT_TOKEN || secrets.GITHUB_TOKEN }} + token: ${{ secrets.RELEASE_BOT_TOKEN }} - name: Derive version from tag id: ver @@ -509,53 +522,11 @@ jobs: - name: Run update-shas.sh run: ./packaging/update-shas.sh "${{ steps.ver.outputs.version }}" - - name: Commit + push - run: | - if git diff --quiet packaging/; then - echo "::notice::packaging/ already up to date — no commit" - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add packaging/ - release_ref="${{ steps.ver.outputs.release_ref }}" - msg="$(printf 'chore(release): sync manifests to %s\n\nAuto-generated by .github/workflows/release.yml after publishing\n%s. Pulls SHA256SUMS.txt from the release and rewrites\ncask / scoop / winget / flatpak manifests to match.\n\n[skip ci]' "$release_ref" "$release_ref")" - git commit -m "$msg" - # Rebase in case main advanced while the release job ran. - git pull --rebase origin main - if git push origin HEAD:main; then - echo "::notice::pushed packaging manifest sync directly to main" - exit 0 - fi - - branch="release/${release_ref}-manifests" - if ! git push --force origin HEAD:"$branch"; then - echo "::warning::Could not push manifest sync branch ${branch}; release assets are already published, but in-repo packaging manifests still need manual sync." - exit 0 - fi - existing_pr="$(gh pr list --head "$branch" --json number --jq '.[0].number // empty')" - if [ -z "$existing_pr" ]; then - if ! gh pr create \ - --base main \ - --head "$branch" \ - --title "chore(release): sync manifests to ${release_ref}" \ - --body "Auto-generated by .github/workflows/release.yml after publishing ${release_ref}. Pulls SHA256SUMS.txt from the release and rewrites cask / scoop / winget / flatpak manifests to match."; then - echo "::warning::Pushed ${branch}, but this token cannot create pull requests. Open it manually to sync packaging manifests." - exit 0 - fi - fi - pr_number="$(gh pr list --head "$branch" --json number --jq '.[0].number')" - if [ -z "$pr_number" ]; then - echo "::warning::Pushed ${branch}, but could not resolve a PR number. Open it manually to sync packaging manifests." - exit 0 - fi - if gh pr merge "$pr_number" --auto --squash; then - echo "::notice::opened packaging manifest PR #${pr_number} and enabled auto-merge" - else - echo "::warning::Opened packaging manifest PR #${pr_number}, but could not enable auto-merge. Review it manually." - fi + - name: Open manifest sync PR + run: bash .github/scripts/sync-release-manifests.sh env: - GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN || secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} + RELEASE_TAG: ${{ steps.ver.outputs.release_ref }} # ------------------------------------------------------------------ # Scoop bucket bump — publishes the generated manifest to the actual diff --git a/packaging/README.md b/packaging/README.md index 28d49686e..b9f635a30 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -1,6 +1,6 @@ # Distribution channels -Canonical sources for Open CoDesign's package manager manifests. The `packaging/` tree is the source of truth; after each stable tag push `release.yml` runs `update-shas.sh` and commits the synced manifests back to `main`. +Canonical sources for Open CoDesign's package manager manifests. The `packaging/` tree is the source of truth; after each stable release `release.yml` runs `update-shas.sh` and opens a PR with the synced manifests. Windows releases publish both NSIS setup exes and portable zip archives. winget uses the setup exes. Scoop uses the zip archives so installation does not depend on unpacking electron-builder's nested NSIS payload; older releases without zip assets use the manifest's legacy NSIS fallback. @@ -27,12 +27,40 @@ packaging/ ## Release flow 1. Push a `vX.Y.Z` tag. `release.yml` builds + publishes the installers. -2. After `publish` succeeds, the **`bump-manifests`** job auto-runs `update-shas.sh`: it pulls `SHA256SUMS.txt` from the release, rewrites versions / URLs / checksums across all four channels (and auto-creates the new winget version directory by copying from the previous one), then commits the diff back to `main` as `chore(release): sync manifests to vX.Y.Z`. +2. After `publish` succeeds, the **`bump-manifests`** job runs `update-shas.sh`: it pulls `SHA256SUMS.txt` from the release, rewrites versions / URLs / checksums across all four channels, and opens or updates `release/vX.Y.Z-manifests` as a PR to `main`. Auto-merge waits for required checks. It never pushes directly to `main` or adds `[skip ci]`. 3. The **`scoop`** job copies the generated Scoop manifest into `OpenCoworkAI/scoop-bucket` when `SCOOP_BUCKET_TOKEN` is configured. 4. The **`homebrew`** job copies the generated cask into `OpenCoworkAI/homebrew-tap` when `HOMEBREW_TAP_TOKEN` is configured. 5. The **`winget`** job submits future bumps after Microsoft accepts the initial package. The v0.2.0 winget PR is microsoft/winget-pkgs#372310. -To run the sync manually (e.g. to backfill a past release or test script changes): +### Manifest PR credentials and native release gate + +Configure the repository Actions secret `RELEASE_BOT_TOKEN` with a fine-grained +PAT restricted to this repository, with **Contents: read/write** and +**Pull requests: read/write** (and required organization approval). Use a dedicated +automation account. This token creates the branch and PR so normal PR checks run; +the built-in `GITHUB_TOKEN` is deliberately not a fallback, because its events do +not start those checks. Do not grant branch-protection bypass or weaken required CI. +Repository auto-merge must be enabled. Missing credentials, failed PR creation, +and failed auto-merge setup fail the sync job visibly, after release publication. +This does not change the separate Homebrew, Scoop, or winget credentials. + +Existing manifest PRs whose commits contain `[skip ci]` need a new commit without +that marker (or regeneration through this workflow); enabling token permissions +alone does not repair their missing CI checks. + +Release publication now waits for the reusable Intel check on `macos-15-intel`, +including architecture validation and native application smoke testing. Related +workflow and macOS packaging changes also run it on PRs. The check only builds +and tests; it does not publish. A release invocation suppresses its extra DMG +artifact so the release's installer downloads stay unambiguous. + +Test the manifest PR logic without credentials or network writes: + +```sh +bash .github/scripts/sync-release-manifests.test.sh +``` + +To run manifest generation manually (e.g. to backfill a past release or test script changes): ```sh ./packaging/update-shas.sh # uses apps/desktop/package.json version