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
41 changes: 41 additions & 0 deletions .github/scripts/sync-release-manifests.sh
Original file line number Diff line number Diff line change
@@ -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"
68 changes: 68 additions & 0 deletions .github/scripts/sync-release-manifests.test.sh
Original file line number Diff line number Diff line change
@@ -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'
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 23 additions & 5 deletions .github/workflows/release-macos-x64-native-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down
85 changes: 28 additions & 57 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down
34 changes: 31 additions & 3 deletions packaging/README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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
Expand Down
Loading