diff --git a/.github/workflows/aur-validate.yml b/.github/workflows/aur-validate.yml index ccf3a3e75e..800c47b834 100644 --- a/.github/workflows/aur-validate.yml +++ b/.github/workflows/aur-validate.yml @@ -52,16 +52,6 @@ on: description: "Optional explicit public URL for x86_64 Electron tarball" required: false type: string - push_to_aur: - description: "Push validated PKGBUILD/.SRCINFO to AUR" - required: false - type: boolean - default: false - aur_repo: - description: "AUR repo name" - required: false - type: string - default: openwork workflow_call: inputs: @@ -97,17 +87,6 @@ on: asset_url_x86_64: required: false type: string - push_to_aur: - required: false - type: boolean - default: false - aur_repo: - required: false - type: string - default: openwork - secrets: - AUR_SSH_PRIVATE_KEY: - required: false permissions: contents: read @@ -132,8 +111,6 @@ jobs: INPUT_ASSET_URL_X86_64: ${{ inputs.asset_url_x86_64 }} INPUT_ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }} INPUT_ARTIFACT_NAME: ${{ inputs.artifact_name || 'openwork-electron-linux-x64' }} - PUSH_TO_AUR: ${{ inputs.push_to_aur && 'true' || 'false' }} - AUR_REPO: ${{ inputs.aur_repo || 'openwork' }} steps: - name: Validate workflow inputs shell: bash @@ -160,11 +137,6 @@ jobs: exit 1 fi - if [ "${PUSH_TO_AUR}" = "true" ] && [ "${ARTIFACT_SOURCE}" = "local-build-artifact" ] && [ -z "${INPUT_ASSET_URL_X86_64:-}" ]; then - echo "For push_to_aur with local-build-artifact, set asset_url_x86_64 to a public URL (or use artifact_source=release)." >&2 - exit 1 - fi - - name: Install Arch packaging dependencies shell: bash run: | @@ -407,55 +379,3 @@ jobs: else echo "No runnable desktop binary found; install sanity check passed." fi - - - name: Publish to AUR - if: env.PUSH_TO_AUR == 'true' - env: - AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} - RELEASE_TAG: v${{ steps.resolve.outputs.version }} - AUR_SKIP_UPDATE: "1" - shell: bash - run: | - set -euo pipefail - - if [ -z "${AUR_SSH_PRIVATE_KEY:-}" ]; then - echo "AUR_SSH_PRIVATE_KEY not set; cannot push to AUR." >&2 - exit 1 - fi - - mkdir -p "$HOME/.ssh" - touch "$HOME/.ssh/known_hosts" - ssh-keygen -R aur.archlinux.org >/dev/null 2>&1 || true - ssh-keyscan -t rsa,ecdsa,ed25519 aur.archlinux.org >> "$HOME/.ssh/known_hosts" 2>/dev/null - - tmp_dir="$(mktemp -d)" - trap 'rm -rf "$tmp_dir"' EXIT - - key_path="$tmp_dir/aur.key" - printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$key_path" - chmod 600 "$key_path" - - aur_remote="ssh://aur@aur.archlinux.org/${AUR_REPO}.git" - export GIT_SSH_COMMAND="ssh -i $key_path -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" - - git clone "$aur_remote" "$tmp_dir/aur" - cp packaging/aur/PKGBUILD "$tmp_dir/aur/PKGBUILD" - cp packaging/aur/.SRCINFO "$tmp_dir/aur/.SRCINFO" - - cd "$tmp_dir/aur" - if git diff --quiet -- PKGBUILD .SRCINFO; then - echo "AUR already up to date for ${AUR_REPO}." - exit 0 - fi - - git add PKGBUILD .SRCINFO - git -c user.name="OpenWork Release Bot" \ - -c user.email="release-bot@users.noreply.github.com" \ - commit -m "chore(aur): update PKGBUILD for ${RELEASE_TAG#v}" - - current_branch="$(git symbolic-ref --short HEAD 2>/dev/null || true)" - if [ -z "$current_branch" ]; then - current_branch="master" - fi - - git push origin "HEAD:${current_branch}" diff --git a/.github/workflows/release-continue.yml b/.github/workflows/release-continue.yml new file mode 100644 index 0000000000..db3dac5dc3 --- /dev/null +++ b/.github/workflows/release-continue.yml @@ -0,0 +1,346 @@ +name: Continue Merged Release + +on: + pull_request_target: + branches: [dev] + types: [closed] + workflow_dispatch: + inputs: + tag: + description: Release tag from a merged release PR (vX.Y.Z) + required: true + type: string + stage: + description: Stage to retry + required: true + type: choice + options: [all, tag, desktop, server, snapshot, aur] + default: all + +permissions: + actions: read + contents: read + pull-requests: read + +concurrency: + group: continue-release-${{ github.event.pull_request.number || inputs.tag }}-${{ inputs.stage || 'all' }} + cancel-in-progress: false + +jobs: + resolve: + name: Resolve reviewed release tree + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'release/v')) + runs-on: ubuntu-latest + outputs: + pr_number: ${{ steps.merge.outputs.pr_number }} + pr_url: ${{ steps.merge.outputs.pr_url }} + merge_sha: ${{ steps.merge.outputs.merge_sha }} + tag: ${{ steps.metadata.outputs.tag }} + version: ${{ steps.metadata.outputs.version }} + branch: ${{ steps.metadata.outputs.branch }} + prepare_run_id: ${{ steps.metadata.outputs.prepare_run_id }} + prepare_run_attempt: ${{ steps.metadata.outputs.prepare_run_attempt }} + build_source_sha: ${{ steps.metadata.outputs.build_source_sha }} + prerelease: ${{ steps.metadata.outputs.prerelease }} + publish_server: ${{ steps.metadata.outputs.publish_server }} + publish_snapshot: ${{ steps.metadata.outputs.publish_snapshot }} + stage: ${{ steps.stage.outputs.stage }} + steps: + - name: Require exact release App configuration + env: + RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + RELEASE_APP_LOGIN: ${{ vars.RELEASE_APP_LOGIN }} + COMMIT_SIGNING_KEY: ${{ secrets.COMMIT_SIGNING_KEY }} + run: | + set -euo pipefail + missing=() + for name in RELEASE_APP_ID RELEASE_APP_PRIVATE_KEY RELEASE_APP_LOGIN COMMIT_SIGNING_KEY; do + if [ -z "${!name:-}" ]; then missing+=("$name"); fi + done + if [ "${#missing[@]}" -gt 0 ]; then + printf 'Missing required release App configuration: %s\n' "${missing[*]}" >&2 + exit 1 + fi + + - name: Checkout trusted resolver from dev + uses: actions/checkout@v6 + with: + ref: dev + fetch-depth: 1 + + - name: List merged PR candidates for manual retry + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/pulls?state=closed&base=dev&per_page=100" \ + > "$RUNNER_TEMP/release-prs.json" + + - name: Resolve trusted merge only + id: merge + env: + INPUT_TAG: ${{ inputs.tag }} + RELEASE_APP_LOGIN: ${{ vars.RELEASE_APP_LOGIN }} + RELEASE_PR_CANDIDATES_PATH: ${{ runner.temp }}/release-prs.json + run: node scripts/release/resolve-merge.mjs + + - name: Checkout exact reviewed merge tree + uses: actions/checkout@v6 + with: + ref: ${{ steps.merge.outputs.merge_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Verify exact merge and fetch reviewed PR head + env: + MERGE_SHA: ${{ steps.merge.outputs.merge_sha }} + PR_NUMBER: ${{ steps.merge.outputs.pr_number }} + run: | + set -euo pipefail + if [ "$(git rev-parse HEAD)" != "$MERGE_SHA" ]; then + echo "Continuation checkout does not match reviewed merge SHA." >&2 + exit 1 + fi + git fetch origin "refs/pull/$PR_NUMBER/head:refs/remotes/origin/release-pr-head" + git fetch origin dev:refs/remotes/origin/dev + git merge-base --is-ancestor "$MERGE_SHA" origin/dev + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.4.0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install trusted release tooling + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Read and validate committed release metadata + id: metadata + env: + EXPECTED_TAG: ${{ steps.merge.outputs.tag }} + EXPECTED_BRANCH: ${{ steps.merge.outputs.branch }} + run: | + node scripts/release/release-metadata.mjs outputs \ + ".github/releases/$EXPECTED_TAG/release.json" \ + ".github/releases/$EXPECTED_TAG/artifacts.json" + + - name: Verify merged release versions and build-source tree boundary + env: + COMMIT_SIGNING_KEY: ${{ secrets.COMMIT_SIGNING_KEY }} + TAG: ${{ steps.metadata.outputs.tag }} + SOURCE_SHA: ${{ steps.metadata.outputs.build_source_sha }} + PR_HEAD: refs/remotes/origin/release-pr-head + MERGE_SHA: ${{ steps.merge.outputs.merge_sha }} + run: | + set -euo pipefail + node scripts/release/verify-tag.mjs --tag "$TAG" + git merge-base --is-ancestor "$SOURCE_SHA" "$PR_HEAD" + pr_head_sha="$(git rev-parse "$PR_HEAD")" + scripts/release/verify-signed-release-branch.sh "$SOURCE_SHA" "$pr_head_sha" "$TAG" + if ! git diff --quiet "$PR_HEAD^{tree}" "$MERGE_SHA^{tree}"; then + echo "Reviewed release branch tree does not exactly match the squash merge tree." >&2 + exit 1 + fi + if [ "$(git rev-parse "$SOURCE_SHA^")" != "$(git rev-parse "$MERGE_SHA^")" ]; then + echo "dev changed after desktop build source; refusing to tag artifacts from a different tree." >&2 + exit 1 + fi + while IFS= read -r path; do + case "$path" in + packaging/aur/PKGBUILD|packaging/aur/.SRCINFO|.github/releases/"$TAG"/*) ;; + *) + echo "Merged release tree differs from desktop build source at $path." >&2 + exit 1 + ;; + esac + done < <(git diff --name-only "$SOURCE_SHA..$MERGE_SHA") + + - name: Resolve requested stage + id: stage + env: + INPUT_STAGE: ${{ inputs.stage }} + run: echo "stage=${INPUT_STAGE:-all}" >> "$GITHUB_OUTPUT" + + tag: + name: Create exact merge tag once + needs: resolve + runs-on: ubuntu-latest + outputs: + action: ${{ steps.tag.outputs.action }} + steps: + - name: Checkout exact merge commit without persisted credentials + uses: actions/checkout@v6 + with: + ref: ${{ needs.resolve.outputs.merge_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Verify merged versions again + env: + TAG: ${{ needs.resolve.outputs.tag }} + run: node scripts/release/verify-tag.mjs --tag "$TAG" + + - name: Mint release App tag-only token + id: release-app-tag + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + + - name: Create or verify immutable tag as release App + id: tag + env: + RELEASE_APP_TOKEN: ${{ steps.release-app-tag.outputs.token }} + RELEASE_APP_SLUG: ${{ steps.release-app-tag.outputs.app-slug }} + RELEASE_APP_LOGIN: ${{ vars.RELEASE_APP_LOGIN }} + TAG: ${{ needs.resolve.outputs.tag }} + MERGE_SHA: ${{ needs.resolve.outputs.merge_sha }} + run: | + set -euo pipefail + actual="${RELEASE_APP_SLUG}[bot]" + if [ "$actual" != "$RELEASE_APP_LOGIN" ]; then + echo "Tag token actor $actual does not match RELEASE_APP_LOGIN=$RELEASE_APP_LOGIN." >&2 + exit 1 + fi + existing="" + if git show-ref --verify --quiet "refs/tags/$TAG"; then + existing="$(git rev-list -n 1 "$TAG")" + fi + decision="$(EXISTING_SHA="$existing" TARGET_SHA="$MERGE_SHA" node --input-type=module <<'NODE' + import { decideTagAction } from "./scripts/release/release-plan.mjs" + console.log(decideTagAction(process.env.EXISTING_SHA, process.env.TARGET_SHA)) + NODE + )" + if [ "$decision" = "keep" ]; then + echo "action=kept" >> "$GITHUB_OUTPUT" + exit 0 + fi + git tag "$TAG" "$MERGE_SHA" + remote="https://x-access-token:${RELEASE_APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + action=created + if ! git push "$remote" "refs/tags/$TAG"; then + if ! git fetch origin "refs/tags/$TAG:refs/remotes/release-tags/$TAG"; then + echo "Release App could not create protected tag $TAG. Configure it only as a v* tag-creation ruleset bypass actor; do not bypass dev." >&2 + exit 1 + fi + existing="$(git rev-list -n 1 "refs/remotes/release-tags/$TAG")" + if [ "$existing" != "$MERGE_SHA" ]; then + echo "Concurrent protected tag $TAG targets $existing, not $MERGE_SHA." >&2 + exit 1 + fi + action=verified-concurrent + fi + echo "action=$action" >> "$GITHUB_OUTPUT" + + desktop: + name: Desktop stage + needs: [resolve, tag] + if: needs.resolve.outputs.stage == 'all' || needs.resolve.outputs.stage == 'desktop' + permissions: + actions: read + contents: write + uses: ./.github/workflows/release-publish-desktop.yml + with: + tag: ${{ needs.resolve.outputs.tag }} + secrets: inherit + + server: + name: Server stage + needs: [resolve, tag] + if: >- + needs.resolve.outputs.stage == 'server' || + (needs.resolve.outputs.stage == 'all' && needs.resolve.outputs.publish_server == 'true') + uses: ./.github/workflows/release-publish-server.yml + with: + tag: ${{ needs.resolve.outputs.tag }} + secrets: inherit + + snapshot: + name: Daytona stage + needs: [resolve, tag] + if: >- + needs.resolve.outputs.stage == 'snapshot' || + (needs.resolve.outputs.stage == 'all' && needs.resolve.outputs.publish_snapshot == 'true') + uses: ./.github/workflows/release-daytona-snapshot.yml + with: + tag: ${{ needs.resolve.outputs.tag }} + secrets: inherit + + aur: + name: AUR stage + needs: [resolve, tag, desktop] + if: >- + always() && needs.tag.result == 'success' && + (needs.resolve.outputs.stage == 'aur' || + (needs.resolve.outputs.stage == 'all' && needs.desktop.result == 'success')) + uses: ./.github/workflows/release-publish-aur.yml + with: + tag: ${{ needs.resolve.outputs.tag }} + secrets: inherit + + summary: + name: Release stage dashboard + needs: [resolve, tag, desktop, server, snapshot, aur] + if: always() && needs.resolve.result == 'success' + runs-on: ubuntu-latest + steps: + - name: Write failure-safe release dashboard + env: + TAG: ${{ needs.resolve.outputs.tag }} + PREPARE_RUN_ID: ${{ needs.resolve.outputs.prepare_run_id }} + PREPARE_RUN_ATTEMPT: ${{ needs.resolve.outputs.prepare_run_attempt }} + MERGE_SHA: ${{ needs.resolve.outputs.merge_sha }} + PR_URL: ${{ needs.resolve.outputs.pr_url }} + TAG_RESULT: ${{ needs.tag.result }} + TAG_ACTION: ${{ needs.tag.outputs.action }} + DESKTOP_RESULT: ${{ needs.desktop.result }} + SERVER_RESULT: ${{ needs.server.result }} + SNAPSHOT_RESULT: ${{ needs.snapshot.result }} + AUR_RESULT: ${{ needs.aur.result }} + run: | + status() { + case "$1" in success) echo passed ;; failure|cancelled) echo failed ;; skipped) echo waiting ;; *) echo pending ;; esac + } + { + echo "## Release dashboard: $TAG" + echo + echo "- Reviewed release PR: $PR_URL" + echo "- Exact merge SHA: \`$MERGE_SHA\`" + echo "- Committed artifact identity: run $PREPARE_RUN_ID attempt $PREPARE_RUN_ATTEMPT" + echo "- This continuation: https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + echo + echo "| Stage | Status | Verified action/result |" + echo "| --- | --- | --- |" + echo "| Tag | $(status "$TAG_RESULT") | ${TAG_ACTION:-not verified} |" + echo "| Desktop | $(status "$DESKTOP_RESULT") | inspect desktop stage summary |" + echo "| Server | $(status "$SERVER_RESULT") | inspect server stage summary |" + echo "| Daytona | $(status "$SNAPSHOT_RESULT") | inspect snapshot stage summary |" + echo "| AUR | $(status "$AUR_RESULT") | inspect AUR stage summary |" + echo + echo "### Exact retries (committed metadata remains authoritative)" + echo '\`\`\`sh' + echo "gh workflow run release-continue.yml --repo $GITHUB_REPOSITORY -f tag=$TAG -f stage=tag" + echo "gh workflow run release-publish-desktop.yml --repo $GITHUB_REPOSITORY -f tag=$TAG" + echo "gh workflow run release-publish-server.yml --repo $GITHUB_REPOSITORY -f tag=$TAG" + echo "gh workflow run release-daytona-snapshot.yml --repo $GITHUB_REPOSITORY -f tag=$TAG" + echo "gh workflow run release-publish-aur.yml --repo $GITHUB_REPOSITORY -f tag=$TAG" + echo '\`\`\`' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-daytona-snapshot.yml b/.github/workflows/release-daytona-snapshot.yml index 289e0c9f21..32fedfadbc 100644 --- a/.github/workflows/release-daytona-snapshot.yml +++ b/.github/workflows/release-daytona-snapshot.yml @@ -215,11 +215,12 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Redeploy den-api so the new pin takes effect + id: redeploy # Render does not redeploy services when an env group value changes, so # den-api keeps serving the previous DAYTONA_SNAPSHOT until it redeploys. # Without this the pin moves but `latestVersion` stays stale and no # sandbox ever recycles - observed for real on v0.18.11. - if: steps.pin.outcome == 'success' + if: steps.pin.outcome == 'success' && vars.DAYTONA_CLOUD_ENV_GROUP_ID != '' shell: bash env: RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} @@ -227,9 +228,13 @@ jobs: run: | set -euo pipefail - if [ -z "${RENDER_DEN_SERVICE_ID:-}" ] || [ -z "${RENDER_API_KEY:-}" ]; then - echo "::notice::Render den-api credentials unavailable; skipping redeploy. The pin will take effect on the next den-api deploy." - exit 0 + if [ -z "${RENDER_DEN_SERVICE_ID:-}" ]; then + echo "Missing required secret: RENDER_DEN_CONTROL_PLANE_SERVICE_ID" >&2 + exit 1 + fi + if [ -z "${RENDER_API_KEY:-}" ]; then + echo "Missing required secret: RENDER_API_KEY" >&2 + exit 1 fi response="$(curl -s -w '\n%{http_code}' -X POST \ @@ -248,3 +253,32 @@ jobs: echo echo "Triggered a den-api deploy so the new pin takes effect." } >> "$GITHUB_STEP_SUMMARY" + + - name: Summarize Daytona publication + if: always() + env: + RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }} + SNAPSHOT_NAME: ${{ steps.resolve.outputs.snapshot_name }} + SNAPSHOT_REGION: ${{ steps.resolve.outputs.snapshot_region }} + PUBLISH_OUTCOME: ${{ steps.publish.outcome }} + PIN_OUTCOME: ${{ steps.pin.outcome }} + REDEPLOY_OUTCOME: ${{ steps.redeploy.outcome }} + run: | + { + echo "## Daytona snapshot publication: $RELEASE_TAG" + echo + echo "- Input: exact tag \`$RELEASE_TAG\`" + echo "- Snapshot publication: ${PUBLISH_OUTCOME:-not run}" + if [ "$PUBLISH_OUTCOME" = "success" ]; then + echo "- Verified snapshot name: \`$SNAPSHOT_NAME\`" + fi + echo "- Region: \`${SNAPSHOT_REGION:-default}\`" + echo "- Production pin: ${PIN_OUTCOME:-not run}" + echo "- den-api redeploy: ${REDEPLOY_OUTCOME:-not run}" + echo "- Workflow: https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + echo + echo "Retry only Daytona publication:" + echo '\`\`\`sh' + echo "gh workflow run release-daytona-snapshot.yml --repo $GITHUB_REPOSITORY -f tag=$RELEASE_TAG" + echo '\`\`\`' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-macos-aarch64.yml b/.github/workflows/release-macos-aarch64.yml index 8b487e3c8a..02099adf98 100644 --- a/.github/workflows/release-macos-aarch64.yml +++ b/.github/workflows/release-macos-aarch64.yml @@ -1,277 +1,37 @@ -name: Release App +name: Stage Release Desktop Artifacts on: - push: - tags: - - "v*" - workflow_dispatch: + workflow_call: inputs: - tag: - description: "Tag to release (e.g., v0.1.2). Leave empty to use current ref." - required: false + source_sha: + description: Exact signed release source commit to build + required: true type: string - release_name: - description: "Release title (defaults to OpenWork )" - required: false - type: string - release_body: - description: "Release notes body in Markdown (defaults to a short placeholder)" - required: false - type: string - draft: - description: "Create the GitHub Release as a draft" - required: false - type: boolean - default: false - prerelease: - description: "Mark the GitHub Release as a prerelease" - required: false - type: boolean - default: false notarize: - description: "Notarize macOS builds (requires Apple team configured)" - required: false - type: boolean - default: true - publish_npm: - description: "Publish openwork-server to npm if its version changed" - required: false - type: boolean - default: true - publish_daytona_snapshot: - description: "Build + push Daytona worker snapshot" - required: false - type: boolean - default: true - build_electron: - description: "Build + publish Electron desktop artifacts (macOS/Linux/Windows)" - required: false + description: Sign and notarize macOS artifacts + required: true type: boolean - default: true sign_windows: - description: "Sign Windows Electron installer with SignPath" - required: false + description: Sign Windows installers with SignPath + required: true type: boolean - default: false permissions: actions: read - contents: write - pull-requests: write - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + contents: read jobs: - resolve-release: - name: Resolve Release Metadata - # SignPath trusted builds require every job leading to the Windows signing - # request to use GitHub-hosted runners. - runs-on: ubuntu-latest - outputs: - release_tag: ${{ steps.resolve.outputs.release_tag }} - release_name: ${{ steps.resolve.outputs.release_name }} - release_body: ${{ steps.resolve.outputs.release_body }} - draft: ${{ steps.resolve.outputs.draft }} - prerelease: ${{ steps.resolve.outputs.prerelease }} - notarize: ${{ steps.resolve.outputs.notarize }} - build_electron: ${{ steps.resolve.outputs.build_electron }} - sign_windows: ${{ steps.resolve.outputs.sign_windows }} - publish_npm: ${{ steps.resolve.outputs.publish_npm }} - publish_daytona_snapshot: ${{ steps.resolve.outputs.publish_daytona_snapshot }} - steps: - - name: Resolve metadata - id: resolve - shell: bash - env: - INPUT_TAG: ${{ github.event.inputs.tag }} - INPUT_RELEASE_NAME: ${{ github.event.inputs.release_name }} - INPUT_RELEASE_BODY: ${{ github.event.inputs.release_body }} - INPUT_DRAFT: ${{ github.event.inputs.draft }} - INPUT_PRERELEASE: ${{ github.event.inputs.prerelease }} - INPUT_NOTARIZE: ${{ github.event.inputs.notarize }} - INPUT_BUILD_ELECTRON: ${{ github.event.inputs.build_electron }} - INPUT_SIGN_WINDOWS: ${{ github.event.inputs.sign_windows }} - INPUT_PUBLISH_NPM: ${{ github.event.inputs.publish_npm }} - INPUT_PUBLISH_DAYTONA_SNAPSHOT: ${{ github.event.inputs.publish_daytona_snapshot }} - DEFAULT_PUBLISH_NPM: ${{ vars.RELEASE_PUBLISH_NPM }} - DEFAULT_PUBLISH_DAYTONA_SNAPSHOT: ${{ vars.RELEASE_PUBLISH_DAYTONA_SNAPSHOT }} - DEFAULT_NOTARIZE: ${{ vars.MACOS_NOTARIZE }} - DEFAULT_BUILD_ELECTRON: ${{ vars.RELEASE_PUBLISH_ELECTRON }} - DEFAULT_SIGN_WINDOWS: ${{ vars.RELEASE_SIGN_WINDOWS }} - run: | - set -euo pipefail - - TAG_INPUT="${INPUT_TAG:-}" - if [ -n "$TAG_INPUT" ]; then - if [[ "$TAG_INPUT" == v* ]]; then - TAG="$TAG_INPUT" - else - TAG="v$TAG_INPUT" - fi - else - TAG="${GITHUB_REF_NAME}" - fi - - if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then - echo "Invalid release tag: $TAG (expected vX.Y.Z)" >&2 - exit 1 - fi - - RELEASE_NAME_INPUT="${INPUT_RELEASE_NAME:-}" - if [ -n "$RELEASE_NAME_INPUT" ]; then - RELEASE_NAME="$RELEASE_NAME_INPUT" - else - RELEASE_NAME="OpenWork $TAG" - fi - - RELEASE_BODY_INPUT="${INPUT_RELEASE_BODY:-}" - if [ -n "$RELEASE_BODY_INPUT" ]; then - RELEASE_BODY="$RELEASE_BODY_INPUT" - else - printf -v RELEASE_BODY '%s\n\nOpenWork %s desktop release.\n\n%s\n%s\n%s\n%s' \ - "## What's new" \ - "$TAG" \ - "- Public artifacts use the openwork-* naming convention." \ - "- Cloud artifacts use openwork-cloud-* and require sign-in without enterprise activation." \ - "- Enterprise artifacts use openwork-enterprise-* while retaining the standard OpenWork app identity." \ - "- Public, Cloud, and enterprise Electron builds use separate updater manifests on this release." - fi - - draft="${INPUT_DRAFT:-}" - if [ -z "$draft" ]; then - if [ "${GITHUB_EVENT_NAME}" = "push" ]; then - # Keep tag-triggered releases out of /releases/latest until assets + latest.json are ready. - draft="true" - else - draft="false" - fi - fi - prerelease="${INPUT_PRERELEASE:-false}" - notarize="${INPUT_NOTARIZE:-}" - if [ -z "$notarize" ]; then - notarize="${DEFAULT_NOTARIZE:-true}" - fi - - build_electron="${INPUT_BUILD_ELECTRON:-}" - if [ -z "$build_electron" ]; then - build_electron="${DEFAULT_BUILD_ELECTRON:-true}" - fi - - sign_windows="${INPUT_SIGN_WINDOWS:-}" - if [ -z "$sign_windows" ]; then - sign_windows="${DEFAULT_SIGN_WINDOWS:-false}" - fi - - if [ "$sign_windows" = "true" ]; then - SIGNPATH_RELEASE_NOTE='*Windows Build free code signing provided by [SignPath.io](https://about.signpath.io), certificate by [SignPath Foundation](https://signpath.org)*' - printf -v RELEASE_BODY '%s\n\n%s' "$RELEASE_BODY" "$SIGNPATH_RELEASE_NOTE" - else - WINDOWS_UNSIGNED_NOTE='*Windows installer is temporarily unsigned while production code signing is being finalized.*' - printf -v RELEASE_BODY '%s\n\n%s' "$RELEASE_BODY" "$WINDOWS_UNSIGNED_NOTE" - fi - - publish_npm="${INPUT_PUBLISH_NPM:-}" - if [ -z "$publish_npm" ]; then - publish_npm="${DEFAULT_PUBLISH_NPM:-true}" - fi - - publish_daytona_snapshot="${INPUT_PUBLISH_DAYTONA_SNAPSHOT:-}" - if [ -z "$publish_daytona_snapshot" ]; then - publish_daytona_snapshot="${DEFAULT_PUBLISH_DAYTONA_SNAPSHOT:-true}" - fi - - TAG="${TAG//$'\n'/}" - TAG="${TAG//$'\r'/}" - RELEASE_NAME="${RELEASE_NAME//$'\n'/ }" - RELEASE_NAME="${RELEASE_NAME//$'\r'/ }" - - echo "release_tag=$TAG" >> "$GITHUB_OUTPUT" - echo "release_name=$RELEASE_NAME" >> "$GITHUB_OUTPUT" - echo "draft=$draft" >> "$GITHUB_OUTPUT" - echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT" - echo "notarize=$notarize" >> "$GITHUB_OUTPUT" - echo "build_electron=$build_electron" >> "$GITHUB_OUTPUT" - echo "sign_windows=$sign_windows" >> "$GITHUB_OUTPUT" - echo "publish_npm=$publish_npm" >> "$GITHUB_OUTPUT" - echo "publish_daytona_snapshot=$publish_daytona_snapshot" >> "$GITHUB_OUTPUT" - { - echo "release_body<<__OPENWORK_RELEASE_BODY_EOF__" - printf '%s\n' "$RELEASE_BODY" - echo "__OPENWORK_RELEASE_BODY_EOF__" - } >> "$GITHUB_OUTPUT" - - - name: Create release if missing - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - BODY_FILE="$RUNNER_TEMP/release_body.md" - printf '%s\n' "${{ steps.resolve.outputs.release_body }}" > "$BODY_FILE" - - if gh release view "${{ steps.resolve.outputs.release_tag }}" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "Release ${{ steps.resolve.outputs.release_tag }} already exists; skipping create." - exit 0 - fi - - PRERELEASE_FLAG="" - if [ "${{ steps.resolve.outputs.prerelease }}" = "true" ]; then - PRERELEASE_FLAG="--prerelease" - fi - - # A new release must never be public while its artifacts are still - # building. The final publish-release job is the only place that - # removes draft status, after every required asset has been checked. - gh release create "${{ steps.resolve.outputs.release_tag }}" \ - --repo "$GITHUB_REPOSITORY" \ - --title "${{ steps.resolve.outputs.release_name }}" \ - --notes-file "$BODY_FILE" \ - --draft $PRERELEASE_FLAG - - verify-release: - name: Verify Release Versions - needs: resolve-release - # SignPath trusted builds require every job leading to the Windows signing - # request to use GitHub-hosted runners. - runs-on: ubuntu-latest - env: - RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ env.RELEASE_TAG }} - fetch-depth: 0 - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: 24 - - - name: Verify tag matches app versions - run: node scripts/release/verify-tag.mjs --tag "$RELEASE_TAG" - - - name: Release review (strict) - run: node scripts/release/review.mjs --strict - - publish-electron: - name: Build + publish Electron (${{ matrix.artifact }}) - needs: [resolve-release, verify-release] - if: needs.resolve-release.outputs.build_electron == 'true' + stage-electron: + name: Stage Electron (${{ matrix.artifact }}) runs-on: ${{ matrix.platform }} timeout-minutes: 120 env: - RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} - MACOS_NOTARIZE: ${{ needs.resolve-release.outputs.notarize }} - SIGN_WINDOWS: ${{ needs.resolve-release.outputs.sign_windows }} + MACOS_NOTARIZE: ${{ inputs.notarize }} + SIGN_WINDOWS: ${{ inputs.sign_windows }} SIGNPATH_ORGANIZATION_ID: ${{ secrets.SIGNPATH_ORGANIZATION_ID || vars.SIGNPATH_ORGANIZATION_ID }} SIGNPATH_PROJECT_SLUG: ${{ secrets.SIGNPATH_PROJECT_SLUG || vars.SIGNPATH_PROJECT_SLUG }} SIGNPATH_SIGNING_POLICY_SLUG: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG || vars.SIGNPATH_SIGNING_POLICY_SLUG }} SIGNPATH_ARTIFACT_CONFIGURATION_SLUG: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG || vars.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }} - strategy: fail-fast: false matrix: @@ -422,12 +182,23 @@ jobs: target_triple: aarch64-pc-windows-msvc steps: - - name: Checkout + - name: Checkout signed release source uses: actions/checkout@v6 with: - ref: ${{ env.RELEASE_TAG }} + ref: ${{ inputs.source_sha }} fetch-depth: 0 + - name: Verify exact build source + shell: bash + env: + SOURCE_SHA: ${{ inputs.source_sha }} + run: | + set -euo pipefail + if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] || [ "$(git rev-parse HEAD)" != "$SOURCE_SHA" ]; then + echo "Desktop build did not checkout exact source SHA $SOURCE_SHA." >&2 + exit 1 + fi + - name: Enable git long paths (Windows) if: matrix.os_type == 'windows' shell: pwsh @@ -446,7 +217,6 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - # Bun >= 1.3.10 is required by the server build and package-time helpers. bun-version: 1.3.10 - name: Get pnpm store path @@ -474,79 +244,52 @@ jobs: APPLE_NOTARY_API_ISSUER_ID: ${{ secrets.APPLE_NOTARY_API_ISSUER_ID }} APPLE_CODESIGN_CERT_P12_BASE64: ${{ secrets.APPLE_CODESIGN_CERT_P12_BASE64 }} APPLE_CODESIGN_CERT_PASSWORD: ${{ secrets.APPLE_CODESIGN_CERT_PASSWORD }} + shell: bash run: | set -euo pipefail - missing=() - for name in \ - APPLE_NOTARY_API_KEY_P8_BASE64 \ - APPLE_NOTARY_API_KEY_ID \ - APPLE_NOTARY_API_ISSUER_ID \ - APPLE_CODESIGN_CERT_P12_BASE64 \ - APPLE_CODESIGN_CERT_PASSWORD; do - if [ -z "${!name:-}" ]; then - missing+=("$name") - fi + for name in APPLE_NOTARY_API_KEY_P8_BASE64 APPLE_NOTARY_API_KEY_ID APPLE_NOTARY_API_ISSUER_ID APPLE_CODESIGN_CERT_P12_BASE64 APPLE_CODESIGN_CERT_PASSWORD; do + if [ -z "${!name:-}" ]; then missing+=("$name"); fi done if [ "${#missing[@]}" -gt 0 ]; then - printf 'Missing Electron macOS notarization/signing secrets: %s\n' "${missing[*]}" >&2 + printf 'Missing Electron macOS signing secrets: %s\n' "${missing[*]}" >&2 exit 1 fi + key_path="$RUNNER_TEMP/AuthKey.p8" + printf '%s' "$APPLE_NOTARY_API_KEY_P8_BASE64" | base64 --decode > "$key_path" + chmod 600 "$key_path" + echo "NOTARY_KEY_PATH=$key_path" >> "$GITHUB_ENV" - NOTARY_KEY_PATH="$RUNNER_TEMP/AuthKey.p8" - printf '%s' "$APPLE_NOTARY_API_KEY_P8_BASE64" | base64 --decode > "$NOTARY_KEY_PATH" - chmod 600 "$NOTARY_KEY_PATH" - - echo "NOTARY_KEY_PATH=$NOTARY_KEY_PATH" >> "$GITHUB_ENV" - - - name: Reject unnotarized macOS Electron release + - name: Require notarized macOS release artifacts if: matrix.os_type == 'macos' && env.MACOS_NOTARIZE != 'true' - shell: bash run: | - echo "macOS Electron release assets must be notarized. Re-run with notarize=true or set MACOS_NOTARIZE=true." >&2 + echo "Stable macOS release assets must be notarized." >&2 exit 1 - name: Build Electron app shell: bash env: - # TARGET tells prepare-sidecar.mjs which architecture's opencode binary - # to download (critical for cross-arch builds like x64 on arm64 mac). TARGET: ${{ matrix.target_triple }} run: pnpm --filter @openwork/desktop build:electron - - name: Package Electron (macOS, signed + notarized) - if: matrix.os_type == 'macos' && env.MACOS_NOTARIZE == 'true' + - name: Package Electron (macOS) + if: matrix.os_type == 'macos' env: CSC_LINK: ${{ secrets.APPLE_CODESIGN_CERT_P12_BASE64 }} CSC_KEY_PASSWORD: ${{ secrets.APPLE_CODESIGN_CERT_PASSWORD }} APPLE_API_KEY: ${{ secrets.APPLE_NOTARY_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_NOTARY_API_ISSUER_ID }} APPLE_API_KEY_PATH: ${{ env.NOTARY_KEY_PATH }} - run: | - set -euo pipefail - pnpm --dir apps/desktop exec electron-builder \ - --config ${{ matrix.builder_config }} \ - ${{ matrix.electron_args }} \ - --publish never + run: pnpm --dir apps/desktop exec electron-builder --config ${{ matrix.builder_config }} ${{ matrix.electron_args }} --publish never - name: Package Electron (Linux) if: matrix.os_type == 'linux' - run: | - set -euo pipefail - pnpm --dir apps/desktop exec electron-builder \ - --config ${{ matrix.builder_config }} \ - ${{ matrix.electron_args }} \ - --publish never + run: pnpm --dir apps/desktop exec electron-builder --config ${{ matrix.builder_config }} ${{ matrix.electron_args }} --publish never - name: Package Electron (Windows) if: matrix.os_type == 'windows' shell: bash - run: | - set -euo pipefail - pnpm --dir apps/desktop exec electron-builder \ - --config ${{ matrix.builder_config }} \ - ${{ matrix.electron_args }} \ - --publish never + run: pnpm --dir apps/desktop exec electron-builder --config ${{ matrix.builder_config }} ${{ matrix.electron_args }} --publish never - name: Validate SignPath configuration (Windows) if: matrix.os_type == 'windows' && env.SIGN_WINDOWS == 'true' @@ -555,18 +298,10 @@ jobs: SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} run: | set -euo pipefail - missing=() - for name in \ - SIGNPATH_API_TOKEN \ - SIGNPATH_ORGANIZATION_ID \ - SIGNPATH_PROJECT_SLUG \ - SIGNPATH_SIGNING_POLICY_SLUG; do - if [ -z "${!name:-}" ]; then - missing+=("$name") - fi + for name in SIGNPATH_API_TOKEN SIGNPATH_ORGANIZATION_ID SIGNPATH_PROJECT_SLUG SIGNPATH_SIGNING_POLICY_SLUG; do + if [ -z "${!name:-}" ]; then missing+=("$name"); fi done - if [ "${#missing[@]}" -gt 0 ]; then printf 'Missing Windows SignPath configuration: %s\n' "${missing[*]}" >&2 exit 1 @@ -577,13 +312,11 @@ jobs: shell: bash run: | set -euo pipefail - mkdir -p "$RUNNER_TEMP/signpath-unsigned" shopt -s nullglob installers=(apps/desktop/dist-electron/${{ matrix.asset_prefix }}-win-*.exe) if [ "${#installers[@]}" -ne 1 ]; then - printf 'Expected exactly one unsigned Windows installer, found %s.\n' "${#installers[@]}" >&2 - printf '%s\n' "${installers[@]}" >&2 + echo "Expected exactly one unsigned Windows installer." >&2 exit 1 fi cp "${installers[0]}" "$RUNNER_TEMP/signpath-unsigned/" @@ -628,16 +361,11 @@ jobs: shell: bash run: node scripts/release/apply-signpath-windows-artifact.mjs "$RUNNER_TEMP/signpath-signed" apps/desktop/dist-electron - - name: Use unsigned Windows installer fallback + - name: Record unsigned Windows installer if: matrix.os_type == 'windows' && env.SIGN_WINDOWS != 'true' - shell: bash - run: | - set -euo pipefail - echo "::warning::Publishing unsigned Windows installer because SIGN_WINDOWS is not enabled." + run: echo "::warning::Staging an unsigned Windows installer because sign_windows=false." - # electron-builder may still emit latest*.yml for a custom distribution. - # Normalize each non-public feed so it cannot update into another flavor. - - name: Normalize custom distribution updater manifests + - name: Normalize custom updater manifests if: matrix.asset_prefix != 'openwork' shell: bash run: | @@ -648,323 +376,50 @@ jobs: channel="${channel#openwork-}" for manifest in latest*.yml; do target="${channel}${manifest#latest}" - if [ -e "$target" ]; then - rm -f "$manifest" - else - mv "$manifest" "$target" - fi + if [ -e "$target" ]; then rm -f "$manifest"; else mv "$manifest" "$target"; fi done manifests=("${channel}"*.yml) - if [ ${#manifests[@]} -eq 0 ]; then + if [ "${#manifests[@]}" -eq 0 ]; then echo "${channel} build produced no updater manifest." >&2 exit 1 fi - printf '%s\n' "${manifests[@]}" - - name: Upload Electron release assets - env: - GH_TOKEN: ${{ github.token }} + - name: Assemble immutable desktop stage shell: bash run: | set -euo pipefail shopt -s nullglob + stage="$RUNNER_TEMP/release-stage" + mkdir -p "$stage/assets" "$stage/manifests" assets=(apps/desktop/dist-electron/${{ matrix.asset_prefix }}-*) - if [ ${#assets[@]} -eq 0 ]; then - echo "No Electron release assets found in apps/desktop/dist-electron" >&2 + manifests=(apps/desktop/dist-electron/${{ matrix.manifest_pattern }}) + if [ "${#assets[@]}" -eq 0 ] || [ "${#manifests[@]}" -eq 0 ]; then + echo "Expected release assets and updater manifests." >&2 exit 1 fi - gh release upload "$RELEASE_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber - - - name: Upload Electron updater manifest artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.artifact }} - path: apps/desktop/dist-electron/${{ matrix.manifest_pattern }} - if-no-files-found: error - - publish-electron-assets: - name: Publish Electron Assets - needs: [resolve-release, verify-release, publish-electron] - if: | - always() && - needs.resolve-release.outputs.build_electron == 'true' && - needs.resolve-release.result == 'success' && - needs.verify-release.result == 'success' && - needs.publish-electron.result == 'success' - runs-on: blacksmith-4vcpu-ubuntu-2404 - env: - RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - # Dispatch-based release recovery uses the workflow ref so fixes to - # release asset publishing can run without moving an already-shipped tag. - ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || env.RELEASE_TAG }} - fetch-depth: 0 - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: 24 - - - name: Download Electron updater manifest artifacts - uses: actions/download-artifact@v4 - with: - pattern: electron-* - path: ${{ runner.temp }}/electron-dist - - - name: Upload merged updater manifests - env: - GH_TOKEN: ${{ github.token }} - run: node scripts/release/publish-electron-assets.mjs --manifests-only "${{ runner.temp }}/electron-dist" "$RELEASE_TAG" - - publish-npm: - name: Publish openwork-server - needs: [resolve-release, verify-release] - if: | - always() && - needs.resolve-release.result == 'success' && - needs.verify-release.result == 'success' && - needs.resolve-release.outputs.publish_npm == 'true' - runs-on: blacksmith-4vcpu-ubuntu-2404 - env: - RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - # Dispatch-based release recovery uses the workflow ref so fixes to - # npm release automation can run without moving an already-shipped tag. - ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || env.RELEASE_TAG }} - fetch-depth: 0 - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: 24 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 11.4.0 - - - name: Setup Bun - uses: oven-sh/setup-bun@v1 - with: - # Bun builds the openwork-server executable before npm publish. - bun-version: "1.3.10" - - - name: Get pnpm store path - id: pnpm-store - shell: bash - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - name: Cache pnpm store - uses: actions/cache@v5 - continue-on-error: true - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ubuntu-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ubuntu-pnpm- - - - name: Install dependencies - run: pnpm install --frozen-lockfile --prefer-offline - - - name: Resolve package versions - id: package-versions - shell: bash - run: | - node -e "const fs=require('fs'); const server=JSON.parse(fs.readFileSync('apps/server/package.json','utf8')); console.log('server=' + server.version);" >> "$GITHUB_OUTPUT" - - - name: Check npm versions - id: npm-versions - shell: bash - env: - SERVER_VERSION: ${{ steps.package-versions.outputs.server }} - run: | - set -euo pipefail - # npm view exits non-zero for packages that don't exist yet (404). - # Treat missing packages as "not published" so release can publish them. - server_current="$(npm view openwork-server version 2>/dev/null || true)" - - if [ "$server_current" = "$SERVER_VERSION" ]; then - echo "publish_server=false" >> "$GITHUB_OUTPUT" - echo "publish_any=false" >> "$GITHUB_OUTPUT" - else - echo "publish_server=true" >> "$GITHUB_OUTPUT" - echo "publish_any=true" >> "$GITHUB_OUTPUT" - fi + cp "${assets[@]}" "$stage/assets/" + cp "${manifests[@]}" "$stage/manifests/" - - name: Ensure npm auth - id: npm-auth + - name: Record and validate immutable stage identity shell: bash env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - PUBLISH_ANY: ${{ steps.npm-versions.outputs.publish_any }} + SOURCE_SHA: ${{ inputs.source_sha }} run: | set -euo pipefail - - if [ "${PUBLISH_ANY}" != "true" ]; then - echo "enabled=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [ -z "${NPM_TOKEN:-}" ]; then - echo "NPM_TOKEN not set; skipping npm publish." - echo "enabled=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - npm config set //registry.npmjs.org/:_authToken "$NPM_TOKEN" - echo "enabled=true" >> "$GITHUB_OUTPUT" - - - name: Publish openwork-server - if: steps.npm-auth.outputs.enabled == 'true' && steps.npm-versions.outputs.publish_server == 'true' - run: pnpm --filter openwork-server publish:npm --access public - - publish-daytona-snapshot: - name: Build + Push Daytona Snapshot - needs: [resolve-release, verify-release, publish-npm] - if: | - always() && - needs.resolve-release.result == 'success' && - needs.verify-release.result == 'success' && - (needs.publish-npm.result == 'success' || needs.publish-npm.result == 'skipped') && - needs.resolve-release.outputs.publish_daytona_snapshot == 'true' - uses: ./.github/workflows/release-daytona-snapshot.yml - with: - tag: ${{ needs.resolve-release.outputs.release_tag }} - secrets: inherit - - aur-publish: - name: Publish AUR - needs: [resolve-release, publish-electron, publish-release] - if: | - always() && - needs.resolve-release.result == 'success' && - needs.publish-electron.result == 'success' && - needs.publish-release.result == 'success' - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: write - env: - RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} - steps: - - name: Checkout dev - uses: actions/checkout@v6 + version="$(node -p "require('./apps/app/package.json').version")" + node scripts/release/artifact-contract.mjs stage \ + --directory "$RUNNER_TEMP/release-stage" \ + --stage-id "${{ matrix.artifact }}" \ + --version "$version" \ + --source-sha "$SOURCE_SHA" \ + --run-id "$GITHUB_RUN_ID" \ + --run-attempt "$GITHUB_RUN_ATTEMPT" \ + --artifact-name "release-desktop-${{ matrix.artifact }}-attempt-$GITHUB_RUN_ATTEMPT" + + - name: Upload immutable desktop stage + uses: actions/upload-artifact@v4 with: - ref: dev - fetch-depth: 0 - - - name: Update AUR packaging files - run: scripts/aur/update-aur.sh "$RELEASE_TAG" - - - name: Open packaging update PR - id: aur-packaging-pr - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - if ! git status --porcelain -- packaging/aur/PKGBUILD packaging/aur/.SRCINFO | grep -q .; then - echo "AUR packaging already up to date in dev." - echo "created=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - version="${RELEASE_TAG#v}" - branch="automation/aur-${version}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - git add packaging/aur/PKGBUILD packaging/aur/.SRCINFO - git -c user.name="OpenWork Release Bot" \ - -c user.email="release-bot@users.noreply.github.com" \ - commit -m "chore(aur): update PKGBUILD for ${version}" - git push origin "HEAD:${branch}" - - body_file="$RUNNER_TEMP/aur-pr-body.md" - cat > "$body_file" <> "$GITHUB_OUTPUT" - echo "url=$pr_url" >> "$GITHUB_OUTPUT" - { - echo "### AUR packaging PR opened" - echo - echo "$pr_url" - echo - echo "Merge this PR through the protected-branch review flow, then rerun Release App with tag=${RELEASE_TAG}." - } >> "$GITHUB_STEP_SUMMARY" - - - name: Publish to AUR - if: steps.aur-packaging-pr.outputs.created != 'true' - env: - AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} - AUR_REPO: ${{ vars.AUR_REPO || 'openwork' }} - AUR_SKIP_UPDATE: "1" - run: | - set -euo pipefail - if [ -z "${AUR_SSH_PRIVATE_KEY:-}" ]; then - echo "AUR_SSH_PRIVATE_KEY not set; skipping publish to AUR." - exit 0 - fi - scripts/aur/publish-aur.sh "$RELEASE_TAG" - - publish-release: - name: Publish GitHub Release - needs: - - resolve-release - - verify-release - - publish-electron - - publish-electron-assets - - publish-npm - - publish-daytona-snapshot - if: | - always() && - needs.resolve-release.result == 'success' && - needs.verify-release.result == 'success' && - (needs.publish-electron.result == 'success' || needs.publish-electron.result == 'skipped') && - (needs.publish-electron-assets.result == 'success' || needs.publish-electron-assets.result == 'skipped') && - (needs.publish-npm.result == 'success' || needs.publish-npm.result == 'skipped') && - (needs.publish-daytona-snapshot.result == 'success' || needs.publish-daytona-snapshot.result == 'skipped') - runs-on: blacksmith-4vcpu-ubuntu-2404 - env: - RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} - RELEASE_PRERELEASE: ${{ needs.resolve-release.outputs.prerelease }} - steps: - - name: Publish release after assets are ready - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - # Dispatch-based recovery runs default draft=false but may target a - # draft release created by an earlier tag push; always flip drafts - # here so AUR and updater feeds see public download URLs. - is_draft="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft -q .isDraft)" - if [ "$is_draft" != "true" ]; then - echo "Release $RELEASE_TAG is already published; nothing to do." - exit 0 - fi - - if [ "${RELEASE_PRERELEASE}" = "true" ]; then - gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease - else - gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest - fi + name: release-desktop-${{ matrix.artifact }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/release-stage + if-no-files-found: error + retention-days: 90 diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 0000000000..e0473e4414 --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,529 @@ +name: Prepare Release + +on: + workflow_dispatch: + inputs: + bump: + description: Stable version bump + required: true + type: choice + options: [patch, minor, major] + default: patch + release_name: + description: Optional GitHub release title + required: false + type: string + release_body: + description: Optional GitHub release notes + required: false + type: string + prerelease: + description: Mark the GitHub release as a prerelease + required: true + type: boolean + default: false + notarize: + description: Sign and notarize macOS artifacts + required: true + type: boolean + default: true + sign_windows: + description: Sign Windows installers with SignPath + required: true + type: boolean + default: false + publish_server: + description: Publish openwork-server after merge + required: true + type: boolean + default: true + publish_daytona_snapshot: + description: Publish the Daytona snapshot after merge + required: true + type: boolean + default: true + +permissions: + actions: read + contents: write + +concurrency: + group: stable-release-prepare + cancel-in-progress: false + +jobs: + plan: + name: Plan or resume signed release source + runs-on: ubuntu-latest + outputs: + version: ${{ steps.plan.outputs.version }} + tag: ${{ steps.plan.outputs.tag }} + branch: ${{ steps.plan.outputs.branch }} + source_sha: ${{ steps.source.outputs.source_sha }} + steps: + - name: Checkout dev + uses: actions/checkout@v6 + with: + ref: dev + fetch-depth: 0 + persist-credentials: false + + - name: Require release App and commit signing configuration + env: + RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + RELEASE_APP_LOGIN: ${{ vars.RELEASE_APP_LOGIN }} + COMMIT_SIGNING_KEY: ${{ secrets.COMMIT_SIGNING_KEY }} + run: | + set -euo pipefail + missing=() + for name in RELEASE_APP_ID RELEASE_APP_PRIVATE_KEY RELEASE_APP_LOGIN COMMIT_SIGNING_KEY; do + if [ -z "${!name:-}" ]; then missing+=("$name"); fi + done + if [ "${#missing[@]}" -gt 0 ]; then + printf 'Missing required release configuration: %s\n' "${missing[*]}" >&2 + exit 1 + fi + if [[ ! "$RELEASE_APP_LOGIN" =~ ^[A-Za-z0-9-]+\[bot\]$ ]]; then + echo "RELEASE_APP_LOGIN must be the exact installation bot login ending in [bot]." >&2 + exit 1 + fi + echo "::warning::External setup blocker: $RELEASE_APP_LOGIN must be the narrowly scoped bypass actor for v* tag creation only, and must not bypass dev. The workflow cannot safely grant or verify that repository ruleset setting." + + - name: Mint release App source-branch token + id: release-app-preflight + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + + - name: Verify exact release App identity + env: + RELEASE_APP_SLUG: ${{ steps.release-app-preflight.outputs.app-slug }} + RELEASE_APP_LOGIN: ${{ vars.RELEASE_APP_LOGIN }} + run: | + set -euo pipefail + actual="${RELEASE_APP_SLUG}[bot]" + if [ "$actual" != "$RELEASE_APP_LOGIN" ]; then + echo "Release App token actor $actual does not match RELEASE_APP_LOGIN=$RELEASE_APP_LOGIN." >&2 + exit 1 + fi + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.4.0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install release tooling + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Resolve deterministic release plan + id: plan + env: + BUMP: ${{ inputs.bump }} + run: | + set -euo pipefail + current="$(node -p "require('./apps/app/package.json').version")" + plan="$(node scripts/release/release-plan.mjs --current "$current" --bump "$BUMP")" + version="$(jq -r .version <<<"$plan")" + tag="$(jq -r .tag <<<"$plan")" + branch="$(jq -r .branch <<<"$plan")" + if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then + echo "Release tag $tag already exists; retry only the failed publication stage." >&2 + exit 1 + fi + resume=false + if git ls-remote --exit-code --heads origin "refs/heads/$branch" >/dev/null 2>&1; then + resume=true + git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "branch=$branch" >> "$GITHUB_OUTPUT" + echo "resume=$resume" >> "$GITHUB_OUTPUT" + + - name: Bump app, desktop, server, and generated versions + if: steps.plan.outputs.resume != 'true' + env: + VERSION: ${{ steps.plan.outputs.version }} + run: | + set -euo pipefail + pnpm bump:set -- "$VERSION" + pnpm install --lockfile-only + node scripts/release/review.mjs --strict + + - name: Create signed release source commit + if: steps.plan.outputs.resume != 'true' + id: commit + env: + BRANCH: ${{ steps.plan.outputs.branch }} + COMMIT_SIGNING_KEY: ${{ secrets.COMMIT_SIGNING_KEY }} + RELEASE_APP_TOKEN: ${{ steps.release-app-preflight.outputs.token }} + TAG: ${{ steps.plan.outputs.tag }} + run: | + set -euo pipefail + signing_key="$RUNNER_TEMP/commit-signing-key" + trap 'rm -f "$signing_key"' EXIT + umask 077 + printf '%s\n' "$COMMIT_SIGNING_KEY" > "$signing_key" + chmod 600 "$signing_key" + git config --local user.email "11430621+benjaminshafii@users.noreply.github.com" + git config --local user.name "benjaminshafii" + git config --local gpg.format ssh + git config --local user.signingkey "$signing_key" + git switch -c "$BRANCH" + git add apps/app/package.json apps/desktop/package.json apps/server/package.json \ + ee/apps/den-api/src/generated/desktop-versions.ts pnpm-lock.yaml + git commit -S -m "chore(release): $TAG" + remote="https://x-access-token:${RELEASE_APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push "$remote" "HEAD:refs/heads/$BRANCH" + echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Validate and reuse existing signed release branch + if: steps.plan.outputs.resume == 'true' + id: resume + env: + COMMIT_SIGNING_KEY: ${{ secrets.COMMIT_SIGNING_KEY }} + TAG: ${{ steps.plan.outputs.tag }} + BRANCH: ${{ steps.plan.outputs.branch }} + METADATA_PATH: .github/releases/${{ steps.plan.outputs.tag }}/release.json + INDEX_PATH: .github/releases/${{ steps.plan.outputs.tag }}/artifacts.json + run: | + set -euo pipefail + git switch -C "$BRANCH" "origin/$BRANCH" + head_sha="$(git rev-parse HEAD)" + metadata_path=".github/releases/$TAG/release.json" + index_path=".github/releases/$TAG/artifacts.json" + if [ -f "$metadata_path" ] || [ -f "$index_path" ]; then + if [ ! -f "$metadata_path" ] || [ ! -f "$index_path" ]; then + echo "Existing release branch has incomplete committed metadata." >&2 + exit 1 + fi + source_sha="$(EXPECTED_TAG="$TAG" EXPECTED_BRANCH="$BRANCH" node --input-type=module <<'NODE' + import { validateReleaseTree } from "./scripts/release/release-metadata.mjs" + const { metadata } = validateReleaseTree({ + metadataPath: process.env.METADATA_PATH, + indexPath: process.env.INDEX_PATH, + expectedTag: process.env.EXPECTED_TAG, + expectedBranch: process.env.EXPECTED_BRANCH, + }) + process.stdout.write(metadata.buildSourceSha) + NODE + )" + else + source_sha="$head_sha" + fi + if [ "$(git rev-parse "$source_sha^")" != "$(git rev-parse origin/dev)" ]; then + echo "Existing release source is not based on current origin/dev." >&2 + exit 1 + fi + scripts/release/verify-signed-release-branch.sh "$source_sha" "$head_sha" "$TAG" + git switch --detach "$source_sha" + node scripts/release/verify-tag.mjs --tag "$TAG" + node scripts/release/review.mjs --strict + echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT" + + - name: Resolve exact build source + id: source + env: + FRESH_SOURCE: ${{ steps.commit.outputs.source_sha }} + RESUMED_SOURCE: ${{ steps.resume.outputs.source_sha }} + COMMIT_SIGNING_KEY: ${{ secrets.COMMIT_SIGNING_KEY }} + TAG: ${{ steps.plan.outputs.tag }} + run: | + set -euo pipefail + source_sha="${FRESH_SOURCE:-$RESUMED_SOURCE}" + if [[ ! "$source_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "Could not resolve exact signed build source." >&2 + exit 1 + fi + scripts/release/verify-signed-release-branch.sh "$source_sha" "$source_sha" "$TAG" + echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT" + + stage-desktop: + name: Build immutable desktop stage + needs: plan + uses: ./.github/workflows/release-macos-aarch64.yml + with: + source_sha: ${{ needs.plan.outputs.source_sha }} + notarize: ${{ inputs.notarize }} + sign_windows: ${{ inputs.sign_windows }} + secrets: inherit + + finalize: + name: Finalize one-review release PR + needs: [plan, stage-desktop] + runs-on: ubuntu-latest + steps: + - name: Checkout deterministic release branch + uses: actions/checkout@v6 + with: + ref: ${{ needs.plan.outputs.branch }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.4.0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install release tooling + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Verify all reused release commits and paths + env: + COMMIT_SIGNING_KEY: ${{ secrets.COMMIT_SIGNING_KEY }} + SOURCE_SHA: ${{ needs.plan.outputs.source_sha }} + TAG: ${{ needs.plan.outputs.tag }} + run: scripts/release/verify-signed-release-branch.sh "$SOURCE_SHA" "$(git rev-parse HEAD)" "$TAG" + + - name: Download exactly this attempt's 18 desktop stages + uses: actions/download-artifact@v8 + with: + pattern: release-desktop-electron-*-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/desktop-stage + + - name: Merge manifests, validate all stages, and update AUR + env: + TAG: ${{ needs.plan.outputs.tag }} + SOURCE_SHA: ${{ needs.plan.outputs.source_sha }} + run: | + set -euo pipefail + shopt -s globstar nullglob + version="${TAG#v}" + mkdir -p "$RUNNER_TEMP/merged-manifests" "$RUNNER_TEMP/aur-assets" "$RUNNER_TEMP/release-index" + node scripts/release/publish-electron-assets.mjs \ + --prepare-manifests "$RUNNER_TEMP/desktop-stage" "$RUNNER_TEMP/merged-manifests" + node scripts/release/artifact-contract.mjs index \ + --root "$RUNNER_TEMP/desktop-stage" \ + --manifests "$RUNNER_TEMP/merged-manifests" \ + --version "$version" \ + --source-sha "$SOURCE_SHA" \ + --run-id "$GITHUB_RUN_ID" \ + --run-attempt "$GITHUB_RUN_ATTEMPT" \ + --output "$RUNNER_TEMP/release-index/artifacts.json" + x64=("$RUNNER_TEMP"/desktop-stage/**/assets/openwork-linux-x64-"$version".tar.gz) + arm64=("$RUNNER_TEMP"/desktop-stage/**/assets/openwork-linux-arm64-"$version".tar.gz) + if [ "${#x64[@]}" -ne 1 ] || [ "${#arm64[@]}" -ne 1 ]; then + echo "Artifact index did not yield exactly one public Linux tarball per architecture." >&2 + exit 1 + fi + cp "${x64[0]}" "${arm64[0]}" "$RUNNER_TEMP/aur-assets/" + scripts/aur/update-aur.sh "$TAG" --assets-dir "$RUNNER_TEMP/aur-assets" + + - name: Write and validate reviewed release metadata tree + env: + VERSION: ${{ needs.plan.outputs.version }} + BUILD_SOURCE_SHA: ${{ needs.plan.outputs.source_sha }} + RELEASE_NAME: ${{ inputs.release_name || format('OpenWork {0}', needs.plan.outputs.tag) }} + RELEASE_BODY: ${{ inputs.release_body || format('OpenWork {0} desktop release.', needs.plan.outputs.tag) }} + INPUT_PRERELEASE: ${{ inputs.prerelease }} + INPUT_NOTARIZE: ${{ inputs.notarize }} + INPUT_SIGN_WINDOWS: ${{ inputs.sign_windows }} + INPUT_PUBLISH_SERVER: ${{ inputs.publish_server }} + INPUT_PUBLISH_SNAPSHOT: ${{ inputs.publish_daytona_snapshot }} + TAG: ${{ needs.plan.outputs.tag }} + run: | + set -euo pipefail + release_dir=".github/releases/$TAG" + mkdir -p "$release_dir" + cp "$RUNNER_TEMP/release-index/artifacts.json" "$release_dir/artifacts.json" + node scripts/release/release-metadata.mjs write \ + "$release_dir/release.json" "$release_dir/artifacts.json" + EXPECTED_TAG="$TAG" EXPECTED_BRANCH="release/$TAG" GITHUB_OUTPUT="$RUNNER_TEMP/metadata-outputs" \ + node scripts/release/release-metadata.mjs outputs \ + "$release_dir/release.json" "$release_dir/artifacts.json" + + - name: Upload exact-attempt merged manifests + uses: actions/upload-artifact@v4 + with: + name: release-desktop-manifests-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/merged-manifests + if-no-files-found: error + retention-days: 90 + + - name: Upload exact-attempt artifact index + uses: actions/upload-artifact@v4 + with: + name: release-artifact-index-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/release-index/artifacts.json + if-no-files-found: error + retention-days: 90 + + - name: Mint release App metadata-branch token + id: release-app-branch + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + + - name: Verify metadata-branch token identity + env: + RELEASE_APP_SLUG: ${{ steps.release-app-branch.outputs.app-slug }} + RELEASE_APP_LOGIN: ${{ vars.RELEASE_APP_LOGIN }} + run: | + set -euo pipefail + actual="${RELEASE_APP_SLUG}[bot]" + if [ "$actual" != "$RELEASE_APP_LOGIN" ]; then + echo "Release branch token actor $actual does not match RELEASE_APP_LOGIN=$RELEASE_APP_LOGIN." >&2 + exit 1 + fi + + - name: Sign reviewed AUR and release metadata commit + env: + COMMIT_SIGNING_KEY: ${{ secrets.COMMIT_SIGNING_KEY }} + RELEASE_APP_TOKEN: ${{ steps.release-app-branch.outputs.token }} + TAG: ${{ needs.plan.outputs.tag }} + BRANCH: ${{ needs.plan.outputs.branch }} + run: | + set -euo pipefail + if git diff --quiet -- packaging/aur/PKGBUILD packaging/aur/.SRCINFO ".github/releases/$TAG"; then + echo "Full prepare attempt produced no reviewed metadata change." >&2 + exit 1 + fi + signing_key="$RUNNER_TEMP/commit-signing-key" + trap 'rm -f "$signing_key"' EXIT + umask 077 + printf '%s\n' "$COMMIT_SIGNING_KEY" > "$signing_key" + chmod 600 "$signing_key" + git config --local user.email "11430621+benjaminshafii@users.noreply.github.com" + git config --local user.name "benjaminshafii" + git config --local gpg.format ssh + git config --local user.signingkey "$signing_key" + git add packaging/aur/PKGBUILD packaging/aur/.SRCINFO ".github/releases/$TAG" + git commit -S -m "chore(release): stage immutable metadata for $TAG" + scripts/release/verify-signed-release-branch.sh "${{ needs.plan.outputs.source_sha }}" "$(git rev-parse HEAD)" "$TAG" + remote="https://x-access-token:${RELEASE_APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push "$remote" "HEAD:refs/heads/$BRANCH" + + - name: Mint least-privilege release App PR token + id: release-app-pr + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + + - name: Open final release PR and enable auto-merge as release App + id: pr + env: + GH_TOKEN: ${{ steps.release-app-pr.outputs.token }} + RELEASE_APP_SLUG: ${{ steps.release-app-pr.outputs.app-slug }} + RELEASE_APP_LOGIN: ${{ vars.RELEASE_APP_LOGIN }} + TAG: ${{ needs.plan.outputs.tag }} + BRANCH: ${{ needs.plan.outputs.branch }} + SOURCE_SHA: ${{ needs.plan.outputs.source_sha }} + run: | + set -euo pipefail + actual="${RELEASE_APP_SLUG}[bot]" + if [ "$actual" != "$RELEASE_APP_LOGIN" ]; then + echo "Release PR token actor $actual does not match $RELEASE_APP_LOGIN." >&2 + exit 1 + fi + body_file="$RUNNER_TEMP/release-pr.md" + cat > "$body_file" <&2 + exit 1 + elif [ "$pr_state" = "OPEN" ]; then + gh pr edit "$pr_url" --repo "$GITHUB_REPOSITORY" \ + --title "chore(release): $TAG" --body-file "$body_file" + fi + verified_author="$(gh pr view "$pr_url" --repo "$GITHUB_REPOSITORY" --json author --jq .author.login)" + if [ "$verified_author" != "$RELEASE_APP_LOGIN" ]; then + echo "Release PR author $verified_author does not match $RELEASE_APP_LOGIN." >&2 + exit 1 + fi + if [ "$pr_state" != "MERGED" ]; then + gh pr merge "$pr_url" --repo "$GITHUB_REPOSITORY" --auto --squash --delete-branch + fi + echo "url=$pr_url" >> "$GITHUB_OUTPUT" + + - name: Summarize prepared release + env: + TAG: ${{ needs.plan.outputs.tag }} + PR_URL: ${{ steps.pr.outputs.url }} + run: | + { + echo "## Release prepared: $TAG" + echo + echo "- Verified 18 exact-source stages from run $GITHUB_RUN_ID attempt $GITHUB_RUN_ATTEMPT." + echo "- Reviewed metadata and AUR checksums: $PR_URL" + echo "- Protected-branch review: waiting; auto-merge requested by ${{ vars.RELEASE_APP_LOGIN }}." + echo + echo "Retry the full prepare before merge (attempts are never mixed):" + echo '\`\`\`sh' + echo "gh run rerun $GITHUB_RUN_ID --repo $GITHUB_REPOSITORY" + echo '\`\`\`' + } >> "$GITHUB_STEP_SUMMARY" + + dashboard: + name: Prepare stage dashboard + needs: [plan, stage-desktop, finalize] + if: always() + runs-on: ubuntu-latest + steps: + - name: Write failure-safe prepare dashboard + env: + TAG: ${{ needs.plan.outputs.tag }} + PLAN_RESULT: ${{ needs.plan.result }} + DESKTOP_RESULT: ${{ needs.stage-desktop.result }} + FINALIZE_RESULT: ${{ needs.finalize.result }} + run: | + status() { + case "$1" in success) echo passed ;; failure|cancelled) echo failed ;; skipped) echo waiting ;; *) echo pending ;; esac + } + { + echo "## Prepare dashboard: ${TAG:-unresolved release}" + echo + echo "| Stage | Status | Verified result |" + echo "| --- | --- | --- |" + echo "| Signed source | $(status "$PLAN_RESULT") | ${{ needs.plan.outputs.source_sha }} |" + echo "| Exact 18-stage build | $(status "$DESKTOP_RESULT") | run $GITHUB_RUN_ID attempt $GITHUB_RUN_ATTEMPT |" + echo "| Metadata, AUR, and final PR | $(status "$FINALIZE_RESULT") | inspect job before treating output as published |" + echo + echo "Full prepare retry only:" + echo '\`\`\`sh' + echo "gh run rerun $GITHUB_RUN_ID --repo $GITHUB_REPOSITORY" + echo '\`\`\`' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-publish-aur.yml b/.github/workflows/release-publish-aur.yml new file mode 100644 index 0000000000..87acd9482c --- /dev/null +++ b/.github/workflows/release-publish-aur.yml @@ -0,0 +1,84 @@ +name: Publish Release AUR + +on: + workflow_call: + inputs: + tag: + required: true + type: string + workflow_dispatch: + inputs: + tag: + description: Release tag (vX.Y.Z) + required: true + type: string + +permissions: + contents: read + +concurrency: + group: publish-aur-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + publish-aur: + name: Verify and publish merged AUR files + runs-on: ubuntu-latest + env: + RELEASE_TAG: ${{ inputs.tag }} + AUR_REPO: ${{ vars.AUR_REPO || 'openwork' }} + steps: + - name: Checkout exact merged release tag + uses: actions/checkout@v6 + with: + ref: refs/tags/${{ inputs.tag }} + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Verify merged packaging against immutable release assets + id: verify + run: scripts/aur/verify-aur-assets.sh "$RELEASE_TAG" + + - name: Require AUR publication credentials + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + run: | + if [ -z "${AUR_SSH_PRIVATE_KEY:-}" ]; then + echo "Missing required secret: AUR_SSH_PRIVATE_KEY" >&2 + exit 1 + fi + + - name: Publish merged packaging files to AUR + id: publish + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + AUR_SKIP_UPDATE: "1" + run: | + scripts/aur/publish-aur.sh "$RELEASE_TAG" + echo "action=published-or-verified-current" >> "$GITHUB_OUTPUT" + + - name: Summarize AUR publication + if: always() + env: + VERIFY_OUTCOME: ${{ steps.verify.outcome }} + PUBLISH_ACTION: ${{ steps.publish.outputs.action }} + run: | + { + echo "## AUR publication: $RELEASE_TAG" + echo + echo "- Input: merged \`packaging/aur/PKGBUILD\` and \`.SRCINFO\` at \`$RELEASE_TAG\`" + echo "- Packaging/asset verification: ${VERIFY_OUTCOME:-not run}" + echo "- AUR action: ${PUBLISH_ACTION:-not verified}" + if [ -n "$PUBLISH_ACTION" ]; then + echo "- Verified destination: https://aur.archlinux.org/packages/$AUR_REPO" + fi + echo + echo "Retry only AUR publication:" + echo '\`\`\`sh' + echo "gh workflow run release-publish-aur.yml --repo $GITHUB_REPOSITORY -f tag=$RELEASE_TAG" + echo '\`\`\`' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-publish-desktop.yml b/.github/workflows/release-publish-desktop.yml new file mode 100644 index 0000000000..066d305003 --- /dev/null +++ b/.github/workflows/release-publish-desktop.yml @@ -0,0 +1,209 @@ +name: Publish Staged Desktop Release + +on: + workflow_call: + inputs: + tag: + required: true + type: string + workflow_dispatch: + inputs: + tag: + description: Release tag with committed artifact identity (vX.Y.Z) + required: true + type: string + +permissions: + actions: read + contents: write + +concurrency: + group: publish-desktop-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + publish-desktop: + name: Publish immutable desktop assets + runs-on: ubuntu-latest + env: + RELEASE_TAG: ${{ inputs.tag }} + steps: + - name: Checkout exact release tag + uses: actions/checkout@v6 + with: + ref: refs/tags/${{ inputs.tag }} + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.4.0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install trusted release tooling + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Read authoritative committed release metadata + id: metadata + env: + EXPECTED_TAG: ${{ inputs.tag }} + EXPECTED_BRANCH: release/${{ inputs.tag }} + run: | + node scripts/release/release-metadata.mjs outputs \ + ".github/releases/$RELEASE_TAG/release.json" \ + ".github/releases/$RELEASE_TAG/artifacts.json" + + - name: Verify prepare run identity + env: + GH_TOKEN: ${{ github.token }} + PREPARE_RUN_ID: ${{ steps.metadata.outputs.prepare_run_id }} + PREPARE_RUN_ATTEMPT: ${{ steps.metadata.outputs.prepare_run_attempt }} + run: | + set -euo pipefail + attempt_json="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$PREPARE_RUN_ID/attempts/$PREPARE_RUN_ATTEMPT")" + conclusion="$(jq -r .conclusion <<<"$attempt_json")" + workflow="$(jq -r .path <<<"$attempt_json")" + returned_run_id="$(jq -r .id <<<"$attempt_json")" + returned_attempt="$(jq -r .run_attempt <<<"$attempt_json")" + if [ "$returned_run_id" != "$PREPARE_RUN_ID" ] \ + || [ "$returned_attempt" != "$PREPARE_RUN_ATTEMPT" ] \ + || [ "$conclusion" != "success" ] \ + || [ "$workflow" != ".github/workflows/release-prepare.yml" ]; then + echo "Committed run $PREPARE_RUN_ID attempt $PREPARE_RUN_ATTEMPT is not the exact successful Prepare Release attempt." >&2 + exit 1 + fi + + - name: Verify tag versions + run: node scripts/release/verify-tag.mjs --tag "$RELEASE_TAG" + + - name: Download exactly the committed 18-stage attempt + uses: actions/download-artifact@v8 + with: + pattern: release-desktop-electron-*-attempt-${{ steps.metadata.outputs.prepare_run_attempt }} + run-id: ${{ steps.metadata.outputs.prepare_run_id }} + github-token: ${{ github.token }} + path: ${{ runner.temp }}/desktop-stage + + - name: Download committed merged manifests + uses: actions/download-artifact@v8 + with: + name: ${{ steps.metadata.outputs.merged_manifests_artifact_name }} + run-id: ${{ steps.metadata.outputs.prepare_run_id }} + github-token: ${{ github.token }} + path: ${{ runner.temp }}/desktop-stage/${{ steps.metadata.outputs.merged_manifests_artifact_name }} + + - name: Download committed artifact index + uses: actions/download-artifact@v8 + with: + name: ${{ steps.metadata.outputs.index_artifact_name }} + run-id: ${{ steps.metadata.outputs.prepare_run_id }} + github-token: ${{ github.token }} + path: ${{ runner.temp }}/downloaded-index + + - name: Rebuild and verify complete artifact contract + id: validate + env: + VERSION: ${{ steps.metadata.outputs.version }} + SOURCE_SHA: ${{ steps.metadata.outputs.build_source_sha }} + PREPARE_RUN_ID: ${{ steps.metadata.outputs.prepare_run_id }} + PREPARE_RUN_ATTEMPT: ${{ steps.metadata.outputs.prepare_run_attempt }} + MERGED_ARTIFACT: ${{ steps.metadata.outputs.merged_manifests_artifact_name }} + run: | + set -euo pipefail + committed=".github/releases/$RELEASE_TAG/artifacts.json" + downloaded="$RUNNER_TEMP/downloaded-index/artifacts.json" + rebuilt="$RUNNER_TEMP/rebuilt-artifacts.json" + cmp "$committed" "$downloaded" + node scripts/release/artifact-contract.mjs index \ + --root "$RUNNER_TEMP/desktop-stage" \ + --manifests "$RUNNER_TEMP/desktop-stage/$MERGED_ARTIFACT" \ + --version "$VERSION" \ + --source-sha "$SOURCE_SHA" \ + --run-id "$PREPARE_RUN_ID" \ + --run-attempt "$PREPARE_RUN_ATTEMPT" \ + --output "$rebuilt" + cmp "$committed" "$rebuilt" + echo "action=verified-18-stages-and-index" >> "$GITHUB_OUTPUT" + + - name: Create draft release if missing + id: release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_NAME: ${{ steps.metadata.outputs.release_name }} + RELEASE_BODY_BASE64: ${{ steps.metadata.outputs.release_body_base64 }} + RELEASE_PRERELEASE: ${{ steps.metadata.outputs.prerelease }} + run: | + set -euo pipefail + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "action=kept-existing-release" >> "$GITHUB_OUTPUT" + exit 0 + fi + notes="$RUNNER_TEMP/release-notes.md" + RELEASE_BODY_BASE64="$RELEASE_BODY_BASE64" NOTES_PATH="$notes" node --input-type=module <<'NODE' + import { writeFileSync } from "node:fs" + import { decodeReleaseBody } from "./scripts/release/release-metadata.mjs" + writeFileSync(process.env.NOTES_PATH, `${decodeReleaseBody(process.env.RELEASE_BODY_BASE64)}\n`) + NODE + flags=(--draft) + if [ "$RELEASE_PRERELEASE" = "true" ]; then flags+=(--prerelease); fi + gh release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \ + --title "$RELEASE_NAME" --notes-file "$notes" "${flags[@]}" + echo "action=created-draft" >> "$GITHUB_OUTPUT" + + - name: Publish only missing or byte-identical indexed assets + id: assets + env: + GH_TOKEN: ${{ github.token }} + run: | + node scripts/release/staged-assets.mjs "$RUNNER_TEMP/desktop-stage" "$RELEASE_TAG" + echo "action=verified-and-uploaded-immutable-assets" >> "$GITHUB_OUTPUT" + + - name: Publish completed GitHub release + id: publish + env: + GH_TOKEN: ${{ github.token }} + RELEASE_PRERELEASE: ${{ steps.metadata.outputs.prerelease }} + run: | + set -euo pipefail + is_draft="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft -q .isDraft)" + if [ "$is_draft" != "true" ]; then + echo "action=verified-already-public" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$RELEASE_PRERELEASE" = "true" ]; then + gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease + else + gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest + fi + echo "action=published" >> "$GITHUB_OUTPUT" + + - name: Summarize desktop publication + if: always() + env: + VALIDATION_ACTION: ${{ steps.validate.outputs.action }} + RELEASE_ACTION: ${{ steps.release.outputs.action }} + ASSET_ACTION: ${{ steps.assets.outputs.action }} + PUBLISH_ACTION: ${{ steps.publish.outputs.action }} + PREPARE_RUN_ID: ${{ steps.metadata.outputs.prepare_run_id }} + PREPARE_RUN_ATTEMPT: ${{ steps.metadata.outputs.prepare_run_attempt }} + run: | + { + echo "## Desktop publication: $RELEASE_TAG" + echo + echo "- Artifact validation: ${VALIDATION_ACTION:-not verified}" + echo "- Release record: ${RELEASE_ACTION:-not verified}" + echo "- Asset action: ${ASSET_ACTION:-not verified}" + echo "- Public release action: ${PUBLISH_ACTION:-not verified}" + if [ -n "$PUBLISH_ACTION" ]; then + echo "- Verified destination: https://github.com/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG" + fi + echo + echo "Retry only desktop publication:" + echo '\`\`\`sh' + echo "gh workflow run release-publish-desktop.yml --repo $GITHUB_REPOSITORY -f tag=$RELEASE_TAG" + echo '\`\`\`' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-publish-server.yml b/.github/workflows/release-publish-server.yml new file mode 100644 index 0000000000..8fcb8b3322 --- /dev/null +++ b/.github/workflows/release-publish-server.yml @@ -0,0 +1,114 @@ +name: Publish Release Server + +on: + workflow_call: + inputs: + tag: + required: true + type: string + workflow_dispatch: + inputs: + tag: + description: Release tag (vX.Y.Z) + required: true + type: string + +permissions: + contents: read + +concurrency: + group: publish-server-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + publish-server: + name: Publish openwork-server + runs-on: ubuntu-latest + env: + RELEASE_TAG: ${{ inputs.tag }} + steps: + - name: Checkout exact release tag + uses: actions/checkout@v6 + with: + ref: refs/tags/${{ inputs.tag }} + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.4.0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.10 + + - name: Verify tag versions + run: node scripts/release/verify-tag.mjs --tag "$RELEASE_TAG" + + - name: Resolve idempotent npm publication + id: npm + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + version="${RELEASE_TAG#v}" + stdout="$RUNNER_TEMP/npm-view.out" + stderr="$RUNNER_TEMP/npm-view.err" + set +e + pnpm view "openwork-server@$version" version --json > "$stdout" 2> "$stderr" + status=$? + set -e + decision="$(node scripts/release/npm-publication.mjs \ + --version "$version" --status "$status" --stdout "$stdout" --stderr "$stderr")" + if [ "$decision" = "keep" ]; then + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "action=verified-already-published" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ -z "${NPM_TOKEN:-}" ]; then + echo "Missing required secret: NPM_TOKEN" >&2 + exit 1 + fi + printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" > "$HOME/.npmrc" + chmod 600 "$HOME/.npmrc" + echo "publish=true" >> "$GITHUB_OUTPUT" + echo "action=confirmed-absent-404" >> "$GITHUB_OUTPUT" + + - name: Install dependencies + if: steps.npm.outputs.publish == 'true' + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Publish openwork-server + id: publish + if: steps.npm.outputs.publish == 'true' + run: | + pnpm --filter openwork-server publish:npm --access public + echo "action=published" >> "$GITHUB_OUTPUT" + + - name: Summarize server publication + if: always() + env: + LOOKUP_ACTION: ${{ steps.npm.outputs.action }} + PUBLISH_ACTION: ${{ steps.publish.outputs.action }} + run: | + { + echo "## Server publication: $RELEASE_TAG" + echo + echo "- Input: exact tag \`$RELEASE_TAG\`" + echo "- Registry lookup: ${LOOKUP_ACTION:-not verified}" + echo "- Publish action: ${PUBLISH_ACTION:-not required or not verified}" + if [ "$LOOKUP_ACTION" = "verified-already-published" ] || [ "$PUBLISH_ACTION" = "published" ]; then + echo "- Verified destination: https://www.npmjs.com/package/openwork-server/v/${RELEASE_TAG#v}" + fi + echo + echo "Retry only server publication:" + echo '\`\`\`sh' + echo "gh workflow run release-publish-server.yml --repo $GITHUB_REPOSITORY -f tag=$RELEASE_TAG" + echo '\`\`\`' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.opencode/commands/release.md b/.opencode/commands/release.md index 03da28ac81..eb09c9f690 100644 --- a/.opencode/commands/release.md +++ b/.opencode/commands/release.md @@ -1,21 +1,18 @@ --- -description: Run the OpenWork release flow +description: Prepare a one-review OpenWork release --- -You are running the OpenWork release flow in this repo. +Dispatch the protected composable release flow. Arguments: `$ARGUMENTS`. -Arguments: `$ARGUMENTS` -- If empty, default to a patch release. -- If set to `minor` or `major`, use that bump type. - -Do the following, in order, and stop on any failure: - -1. Sync `dev` and ensure the working tree is clean. -2. Bump app/desktop versions using `pnpm bump:$ARGUMENTS` (or `pnpm bump:patch` if empty). -3. If any dependencies were pinned or changed, run `pnpm install --lockfile-only`. -4. Run `pnpm release:review` and resolve any mismatches. -5. Tag and push: `git tag vX.Y.Z` and `git push origin vX.Y.Z`, then `git push origin dev`. -6. Watch the Release App GitHub Actions workflow to completion. -7. If the `openwork-server` version changed, publish that package. - -Report what you changed, the tag created, and the GHA status. +- Accept only `patch`, `minor`, or `major`; default to `patch`. +- Run `pnpm release:prepare -- --watch`. +- Stop and report the setup blocker if the dedicated release GitHub App + (`RELEASE_APP_ID`, `RELEASE_APP_PRIVATE_KEY`, exact `RELEASE_APP_LOGIN`, and + `v*` tag-creation-only ruleset bypass) is not configured. +- Do not edit versions locally, create a tag, push `dev`, or bypass review. +- Report the Prepare Release run and its final release PR. That single PR already + contains the version changes and AUR checksums from immutable staged artifacts. +- After the human approves it, auto-merge and the merge continuation perform the + release. Do not ask whether it merged and do not manually rerun the whole release. +- If a post-merge stage fails, use the exact stage-only retry printed in the + continuation summary. diff --git a/.opencode/skills/release/SKILL.md b/.opencode/skills/release/SKILL.md index 06ca4a0ede..ae1d2c0d88 100644 --- a/.opencode/skills/release/SKILL.md +++ b/.opencode/skills/release/SKILL.md @@ -1,133 +1,61 @@ # Skill: release -Cut an OpenWork release from `dev`. The "Release App" workflow -(`.github/workflows/release-macos-aarch64.yml`, triggered by a `v*` tag push or -dispatch) builds, signs, and publishes the standard desktop app assets on the -GitHub release. +Stable releases use one protected-branch review and composable GitHub workflows. +Never bump, tag, or push `dev` locally. ---- - -## Prepare - -Work from latest `origin/dev` with a clean tree (use a fresh worktree/branch, -e.g. `release/vX.Y.Z`). Confirm dev CI is green. - -`dev` is protected. Never push directly to `dev`, force-push it, delete it, or -use admin bypasses. All release prep changes must land through a PR with the -normal branch-protection flow: - -- at least one approval; -- code-owner approval where configured; -- stale approvals dismissed after every new push; -- latest push approved by someone other than the pusher; -- all conversations resolved; -- squash or rebase merge only, so the resulting protected-branch commit is - GitHub-created/signed and history stays linear. - ---- - -## Bump +## Start ```bash -pnpm bump:patch # or bump:minor / bump:major / bump:set -- X.Y.Z +pnpm release:prepare -- patch --watch # patch, minor, or major ``` -This updates `apps/app`, `apps/desktop`, `apps/server` -package.json versions, `ee/apps/den-api/src/generated/desktop-versions.ts` -(den-api's `PUBLISHED_DESKTOP_VERSIONS` — the install door redirects to -`v`), and `pnpm-lock.yaml`. Revert incidental -noise (e.g. `*.tsbuildinfo`) before committing. - -Commit as `chore(release): vX.Y.Z`, open a PR against `dev`, and merge only -after the protected-branch requirements above are satisfied. Do not add empty -status-check/workflow requirements just to block a release; use real checks and -human review. - ---- - -## Tag +This dispatches `.github/workflows/release-prepare.yml` on `dev`. The workflow: -Tag the merge commit on dev; the tag push triggers Release App: - -```bash -git fetch origin dev -git tag vX.Y.Z origin/dev -git push origin vX.Y.Z -``` - ---- - -## Watch - -```bash -gh run list --repo different-ai/openwork --workflow "Release App" --limit 1 -gh run watch --repo different-ai/openwork --exit-status --interval 90 -``` +1. creates deterministic `release/vX.Y.Z` signed commits; +2. bumps app, desktop, server, lockfile, and generated desktop versions; +3. builds the public, Cloud, and enterprise 18-target desktop matrix from one + exact source SHA; +4. stores exact-attempt immutable artifacts and commits their complete file/hash + index plus release options under `.github/releases/vX.Y.Z/`; +5. calculates both AUR checksums from those exact staged public Linux tarballs; +6. puts the version and AUR changes in one release PR and enables squash + auto-merge. -The run includes a Windows test job; any test failure blocks publish (the -release stays draft). +The required human review is on that final PR. Do not open an AUR PR, ask the +user to check merge status, or rerun a whole release after merge. -**If the run fails before the release is published:** land the fix on `dev` via -a normal protected-branch PR. Prefer creating a new patch tag if any release -assets may already have been consumed. Only delete/recreate the tag and draft -release when you have verified the GitHub Release is still draft-only and no -published asset was available: - -```bash -git push --delete origin vX.Y.Z -git tag -f vX.Y.Z origin/dev -git push origin vX.Y.Z -``` - -**Rerun without retagging** (e.g. transient failure): - -```bash -gh workflow run "Release App" --repo different-ai/openwork -f tag=vX.Y.Z -``` - -The release workflow may open an AUR packaging PR instead of pushing packaging -updates directly to `dev`. That is expected under branch protection. Get that PR -reviewed and squash/rebase-merged, then rerun the release workflow with the same -tag so the AUR publish step can observe that packaging is already up to date. - -When the workflow opens an AUR packaging PR, immediately inform the user with -the PR URL and the next required action. Then use the `question` tool to ask -exactly: - -> Has the PR been merged? - -Offer `Yes` and `No` options. Continue the release only when the user answers -`Yes`. If the user answers `No`, do not proceed; wait a few minutes, check the -PR merge status with `gh pr view --json merged,state`, and ask the same -question again. Repeat this sleep/check/question loop until the PR is merged or -the user explicitly stops the release. - ---- - -## Verify - -```bash -gh release view vX.Y.Z --repo different-ai/openwork --json assets --jq '.assets[].name' -``` +Before review, a failed prepare may be rerun only as a **full run**. The workflow +validates and reuses its deterministic signed branch and never mixes attempts. -Expect the app assets (`openwork--X.Y.Z.*`, `latest*.yml`), including: +## Continuation -- `openwork-mac-arm64-X.Y.Z.dmg` -- `openwork-mac-x64-X.Y.Z.dmg` -- `openwork-win-x64-X.Y.Z.exe` +Merging the release PR automatically runs `release-continue.yml`. It tags the +exact merge SHA idempotently and starts separate desktop, server, Daytona, and +AUR stages. Continuation trusts only committed reviewed metadata, never the PR +body. Desktop publication downloads exactly the committed run/attempt, rebuilds +the 18-stage index, and will only keep byte-identical existing assets or upload +missing assets; it never rebuilds or clobbers. AUR verifies both packaging files +and pinned SSH host trust before publishing. -Spot-check a download URL resolves (302 to release-assets CDN): +Every workflow summary records inputs, outputs, links, status, and an exact +retry command. Prefer the stage workflow directly: ```bash -curl -sI "https://github.com/different-ai/openwork/releases/download/vX.Y.Z/openwork-mac-arm64-X.Y.Z.dmg" | head -2 +gh workflow run release-publish-desktop.yml --repo different-ai/openwork -f tag=vX.Y.Z +gh workflow run release-publish-server.yml --repo different-ai/openwork -f tag=vX.Y.Z +gh workflow run release-daytona-snapshot.yml --repo different-ai/openwork -f tag=vX.Y.Z +gh workflow run release-publish-aur.yml --repo different-ai/openwork -f tag=vX.Y.Z ``` ---- +Required credentials fail their requested stage: `COMMIT_SIGNING_KEY`, Apple +signing/notary credentials, optional SignPath credentials when requested, +`NPM_TOKEN` when the server version is unpublished, `DAYTONA_API_KEY`, Render +credentials when production pinning is configured, and `AUR_SSH_PRIVATE_KEY`. -## Notes +**Setup blocker:** `RELEASE_APP_ID`, `RELEASE_APP_PRIVATE_KEY`, and exact +`RELEASE_APP_LOGIN` must identify a dedicated least-privilege GitHub App. It +must bypass only `v*` tag creation, never `dev` review, and must not be replaced +by a PAT or admin actor. The currently confirmed ruleset requires this setup +before the flow is operational. -- Desktop installer fixes only reach users through a new release — the org install - door (`/v1/install/:platform`) 302s to versioned assets. -- den deployments built from source pick up the new pin via - `PUBLISHED_DESKTOP_VERSIONS[0]` (den-api `src/version.ts`); no env vars - required. +See `docs/releases.md` for architecture and recovery details. diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000000..9f3f1b4405 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,93 @@ +# Stable releases + +OpenWork stable releases require exactly one human review. Start from the CLI: + +```bash +pnpm release:prepare -- patch --watch +``` + +Or run **Prepare Release** in GitHub Actions and choose `patch`, `minor`, or +`major`. Do not commit version bumps, tag, or push `dev` locally. + +## One-review architecture + +The prepare workflow creates signed commits on deterministic branch +`release/vX.Y.Z`, bumps every release version, and stages all 18 desktop targets +as immutable Actions artifacts. It merges updater manifests and calculates AUR +x86_64/aarch64 checksums from the exact staged public Linux tarballs. Only then +does it open one PR containing both version and packaging changes and enable +auto-merge. + +The reviewed tree records authoritative release data in +`.github/releases/vX.Y.Z/release.json` and the complete 18-stage file/hash index +in `artifacts.json`. The PR body is informational only. Each matrix artifact +records its exact source SHA, run ID, attempt, files, sizes, and hashes. Missing, +duplicate, unexpected, or mixed-attempt data fails before the PR opens and is +validated again across runs before publication. + +Review that final diff once. Protected `dev` receives only GitHub's normal +reviewed squash merge; automation never pushes it directly or bypasses review. + +When the release App-authored PR merges, **Continue Merged Release** checks out +the exact merge SHA, validates the committed metadata/tree and exact PR author, +creates `vX.Y.Z` on that merge SHA (or verifies an existing identical tag), and +runs composable stages: + +- **Desktop:** downloads the staged artifacts cross-run. Existing release assets + must be byte-identical; differing assets fail instead of being clobbered. +- **Server:** publishes `openwork-server` from the exact tag, or succeeds when + that version already exists. +- **Daytona:** publishes the exact tagged snapshot independently. +- **AUR:** checks merged `PKGBUILD`/`.SRCINFO` against the same public Linux + assets, then pushes those files directly to AUR. It never opens another PR. + +The continuation summary is the release dashboard. It shows stage states, +inputs, outputs, links, and exact retry commands. Retry only a failed stage; a +completed stage and its artifact bytes remain immutable. Missing publication +credentials fail the requested stage instead of silently skipping it. + +Prepare artifacts are retained for 90 days. Merge the reviewed release PR while +its recorded run/attempt is available. + +If prepare fails before review, rerun the **entire** prepare run. Every full +rerun gets a new attempt; attempts are never mixed. The deterministic release +branch is reused only after its release commits, accepted SSH signer, and +allowed changed paths validate. After merge, retry only the failed publication +stage. + +## Release GitHub App setup — current operational blocker + +**The composable release is not operational until this setup is complete.** The +current `v*` tag ruleset permits only admins/repository administrators. Create a +dedicated GitHub App, install it only on `different-ai/openwork`, and configure: + +- secret `RELEASE_APP_ID`; +- secret `RELEASE_APP_PRIVATE_KEY`; +- repository variable `RELEASE_APP_LOGIN` with the exact installation bot login + (for example `openwork-release[bot]`); +- minimum repository permissions: Contents read/write and Pull requests + read/write; do not grant administration permission; +- add this App, and only this App, as a narrowly scoped bypass actor on the + `v*` **tag creation** ruleset. + +The App must **not** bypass `dev` branch protection and must not be granted an +admin/repository-admin role. Automation has no tag update, force-push, or delete +path: it may only create a missing exact tag or verify an identical one. Do not +substitute a PAT. App-authored PR/merge operations are required so GitHub emits +the merge event that starts continuation. + +## Other repository requirements + +- Repository auto-merge must be enabled. PR operations use only the dedicated + App installation token, not `GITHUB_TOKEN` or a PAT. +- `dev` must keep its normal one-human-review and signed-commit protection; the + release App and Actions actor must have no bypass there. +- `COMMIT_SIGNING_KEY` must be an SSH private key whose public key is registered + as a signing key for the configured release identity. +- Publication secrets are required only when their stage is requested: Apple + signing/notary values, SignPath values when Windows signing is selected, + `NPM_TOKEN`, `DAYTONA_API_KEY` plus configured Render pin credentials, and + `AUR_SSH_PRIVATE_KEY`. +- AUR host keys are accepted only after all official RSA, ECDSA, and Ed25519 + fingerprints published by `https://aur.archlinux.org/` match the pinned + values in `scripts/aur/pin-host-keys.mjs`. diff --git a/evals/voiceovers/composable-release.md b/evals/voiceovers/composable-release.md new file mode 100644 index 0000000000..94256019a3 --- /dev/null +++ b/evals/voiceovers/composable-release.md @@ -0,0 +1,13 @@ +# composable-release — one review ships an observable, resumable release + +1. I launch a patch, minor, or major release from the CLI or GitHub UI. The release dashboard shows the target version and every independent stage. + +2. OpenWork creates a signed release branch, bumps the versions, and stages immutable desktop artifacts. It calculates AUR checksums from those exact staged Linux files and adds the packaging update to the same signed release PR. + +3. When checks pass, I review and approve that final PR once. GitHub auto-merges it, and no later stage asks me to check merge status, approve another PR, or rerun the whole release. + +4. The merge continuation creates the tag once, publishes the already-staged desktop artifacts, and runs server and snapshot publication as separate retryable stages. The published files are the same files whose checksums I reviewed. + +5. AUR publication consumes the merged packaging files and those immutable release assets directly. If AUR fails, I rerun only AUR publication without rebuilding desktop assets or changing checksums. + +6. The orchestrator shows every stage as pending, waiting, passed, or failed, including inputs, outputs, workflow links, and exact retry commands. Completed stages stay immutable and failures remain local. diff --git a/package.json b/package.json index ce16fd5e18..0eafc2f54d 100644 --- a/package.json +++ b/package.json @@ -71,13 +71,14 @@ "release:review": "node scripts/release/review.mjs", "release:prepare": "node scripts/release/prepare.mjs", "release:prepare:dry": "node scripts/release/prepare.mjs --dry-run", - "release:ship": "node scripts/release/ship.mjs", - "release:ship:watch": "node scripts/release/ship.mjs --watch" + "release:prepare:watch": "node scripts/release/prepare.mjs --watch", + "release:test": "node --test scripts/release/*.test.mjs scripts/aur/*.test.mjs" }, "devDependencies": { "@types/node": "^24.0.0", "turbo": "^2.5.5", - "typescript": "^5.9.0" + "typescript": "^5.9.0", + "yaml": "^2.6.1" }, "packageManager": "pnpm@11.4.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6409afd95..5dc8fee8b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: typescript: specifier: ^5.9.0 version: 5.9.3 + yaml: + specifier: ^2.6.1 + version: 2.9.0 apps/app: dependencies: @@ -16577,7 +16580,7 @@ snapshots: postcss-load-config@4.0.2(postcss@8.5.18): dependencies: lilconfig: 3.1.3 - yaml: 2.8.2 + yaml: 2.9.0 optionalDependencies: postcss: 8.5.18 diff --git a/scripts/aur/aur-packaging.mjs b/scripts/aur/aur-packaging.mjs new file mode 100755 index 0000000000..9c31a71139 --- /dev/null +++ b/scripts/aur/aur-packaging.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { basename, resolve } from "node:path"; + +const SHA256 = /^[0-9a-f]{64}$/; +const VERSION = /^\d+\.\d+\.\d+$/; + +export function fileSha256(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function replaceRequired(source, pattern, replacement, label) { + if (!pattern.test(source)) throw new Error(`Missing ${label}.`); + return source.replace(pattern, replacement); +} + +export function updateAurContents({ pkgbuild, srcinfo, version, shaX64, shaArm64, repository }) { + if (!VERSION.test(version)) throw new Error(`Invalid AUR version: ${version}`); + if (!SHA256.test(shaX64) || !SHA256.test(shaArm64)) throw new Error("Invalid AUR SHA-256."); + const releaseBase = `https://github.com/${repository}/releases/download/v${version}`; + const x64Url = `${releaseBase}/openwork-linux-x64-${version}.tar.gz`; + const arm64Url = `${releaseBase}/openwork-linux-arm64-${version}.tar.gz`; + + let nextPkgbuild = replaceRequired(pkgbuild, /^pkgver=.*$/m, `pkgver=${version}`, "PKGBUILD pkgver"); + nextPkgbuild = replaceRequired(nextPkgbuild, /^pkgrel=\d+.*$/m, "pkgrel=1 # pkgrel should change when PKGBUILD does. Standard is to change back to 1 next time. Any interger is valid.", "PKGBUILD pkgrel"); + nextPkgbuild = replaceRequired(nextPkgbuild, /^sha256sums_x86_64=.*$/m, `sha256sums_x86_64=('${shaX64}')`, "PKGBUILD x86_64 checksum"); + nextPkgbuild = replaceRequired(nextPkgbuild, /^sha256sums_aarch64=.*$/m, `sha256sums_aarch64=('${shaArm64}')`, "PKGBUILD aarch64 checksum"); + + let nextSrcinfo = replaceRequired(srcinfo, /^\s*pkgver = .*$/m, `\tpkgver = ${version}`, ".SRCINFO pkgver"); + nextSrcinfo = replaceRequired(nextSrcinfo, /^\s*pkgrel = .*$/m, "\tpkgrel = 1", ".SRCINFO pkgrel"); + nextSrcinfo = replaceRequired(nextSrcinfo, /^\s*source_x86_64 = .*$/m, `\tsource_x86_64 = openwork-${version}-x64.tar.gz::${x64Url}`, ".SRCINFO x86_64 source"); + nextSrcinfo = replaceRequired(nextSrcinfo, /^\s*sha256sums_x86_64 = .*$/m, `\tsha256sums_x86_64 = ${shaX64}`, ".SRCINFO x86_64 checksum"); + nextSrcinfo = replaceRequired(nextSrcinfo, /^\s*source_aarch64 = .*$/m, `\tsource_aarch64 = openwork-${version}-arm64.tar.gz::${arm64Url}`, ".SRCINFO aarch64 source"); + nextSrcinfo = replaceRequired(nextSrcinfo, /^\s*sha256sums_aarch64 = .*$/m, `\tsha256sums_aarch64 = ${shaArm64}`, ".SRCINFO aarch64 checksum"); + + return { pkgbuild: nextPkgbuild, srcinfo: nextSrcinfo }; +} + +export function readAurMetadata(pkgbuild) { + const read = (pattern, label) => { + const match = pkgbuild.match(pattern); + if (!match) throw new Error(`Missing ${label}.`); + return match[1]; + }; + return { + version: read(/^pkgver=(.+)$/m, "PKGBUILD pkgver"), + baseUrl: read(/^url="(.+)"$/m, "PKGBUILD URL"), + sourceX64: read(/^source_x86_64=\("(.+)"\)$/m, "PKGBUILD x86_64 source"), + shaX64: read(/^sha256sums_x86_64=\('([0-9a-f]{64})'\)$/m, "PKGBUILD x86_64 checksum"), + sourceArm64: read(/^source_aarch64=\("(.+)"\)$/m, "PKGBUILD aarch64 source"), + shaArm64: read(/^sha256sums_aarch64=\('([0-9a-f]{64})'\)$/m, "PKGBUILD aarch64 checksum"), + }; +} + +export function readSrcinfoMetadata(srcinfo) { + const read = (pattern, label) => { + const match = srcinfo.match(pattern); + if (!match) throw new Error(`Missing ${label}.`); + return match[1]; + }; + return { + version: read(/^\s*pkgver = (.+)$/m, ".SRCINFO pkgver"), + baseUrl: read(/^\s*url = (.+)$/m, ".SRCINFO URL"), + sourceX64: read(/^\s*source_x86_64 = (.+)$/m, ".SRCINFO x86_64 source"), + shaX64: read(/^\s*sha256sums_x86_64 = ([0-9a-f]{64})$/m, ".SRCINFO x86_64 checksum"), + sourceArm64: read(/^\s*source_aarch64 = (.+)$/m, ".SRCINFO aarch64 source"), + shaArm64: read(/^\s*sha256sums_aarch64 = ([0-9a-f]{64})$/m, ".SRCINFO aarch64 checksum"), + }; +} + +export function verifyAurContents({ pkgbuild, srcinfo, version, shaX64, shaArm64, repository }) { + const pkg = readAurMetadata(pkgbuild); + const info = readSrcinfoMetadata(srcinfo); + const base = `https://github.com/${repository}/releases/download/v${version}`; + const expected = { + version, + baseUrl: `https://github.com/${repository}`, + sourceX64: `openwork-${version}-x64.tar.gz::${base}/openwork-linux-x64-${version}.tar.gz`, + shaX64, + sourceArm64: `openwork-${version}-arm64.tar.gz::${base}/openwork-linux-arm64-${version}.tar.gz`, + shaArm64, + }; + const expectedPkg = { + version, + baseUrl: `https://github.com/${repository}`, + sourceX64: "${pkgname}-${pkgver}-x64.tar.gz::${url}/releases/download/v${pkgver}/openwork-linux-x64-${pkgver}.tar.gz", + shaX64, + sourceArm64: "${pkgname}-${pkgver}-arm64.tar.gz::${url}/releases/download/v${pkgver}/openwork-linux-arm64-${pkgver}.tar.gz", + shaArm64, + }; + if (JSON.stringify(pkg) !== JSON.stringify(expectedPkg)) throw new Error("PKGBUILD release version, sources, or checksums do not match immutable assets."); + if (JSON.stringify(info) !== JSON.stringify(expected)) throw new Error(".SRCINFO release version, sources, or checksums do not match immutable assets."); +} + +function flag(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : ""; +} + +function main() { + const command = process.argv[2]; + const root = resolve(import.meta.dirname, "../.."); + const pkgbuildPath = resolve(root, "packaging/aur/PKGBUILD"); + const srcinfoPath = resolve(root, "packaging/aur/.SRCINFO"); + const tag = flag("--tag"); + const version = tag.startsWith("v") ? tag.slice(1) : tag; + const x64Path = resolve(flag("--x64")); + const arm64Path = resolve(flag("--arm64")); + const actual = { + version, + shaX64: fileSha256(x64Path), + shaArm64: fileSha256(arm64Path), + }; + + if (basename(x64Path) !== `openwork-linux-x64-${version}.tar.gz`) { + throw new Error(`Unexpected x86_64 staged asset: ${basename(x64Path)}`); + } + if (basename(arm64Path) !== `openwork-linux-arm64-${version}.tar.gz`) { + throw new Error(`Unexpected aarch64 staged asset: ${basename(arm64Path)}`); + } + + if (command === "update") { + const updated = updateAurContents({ + pkgbuild: readFileSync(pkgbuildPath, "utf8"), + srcinfo: readFileSync(srcinfoPath, "utf8"), + ...actual, + repository: process.env.GITHUB_REPOSITORY ?? "different-ai/openwork", + }); + writeFileSync(pkgbuildPath, updated.pkgbuild); + writeFileSync(srcinfoPath, updated.srcinfo); + } else if (command === "verify") { + verifyAurContents({ + pkgbuild: readFileSync(pkgbuildPath, "utf8"), + srcinfo: readFileSync(srcinfoPath, "utf8"), + ...actual, + repository: process.env.GITHUB_REPOSITORY ?? "different-ai/openwork", + }); + } else { + throw new Error("Usage: aur-packaging.mjs update|verify --tag vX.Y.Z --x64 --arm64 "); + } + + console.log(JSON.stringify(actual)); +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/aur/aur-packaging.test.mjs b/scripts/aur/aur-packaging.test.mjs new file mode 100644 index 0000000000..129c1445d3 --- /dev/null +++ b/scripts/aur/aur-packaging.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { test } from "node:test"; +import { updateAurContents, verifyAurContents } from "./aur-packaging.mjs"; +import { AUR_HOST_FINGERPRINTS, validateHostFingerprints } from "./pin-host-keys.mjs"; + +const root = resolve(import.meta.dirname, "../.."); +const digest = (value) => createHash("sha256").update(value).digest("hex"); + +test("AUR files use checksums from exact staged public Linux bytes", () => { + const original = { + pkgbuild: readFileSync(resolve(root, "packaging/aur/PKGBUILD"), "utf8"), + srcinfo: readFileSync(resolve(root, "packaging/aur/.SRCINFO"), "utf8"), + }; + const input = { + ...original, + version: "9.8.7", + shaX64: digest("staged x64 bytes"), + shaArm64: digest("staged arm64 bytes"), + repository: "different-ai/openwork", + }; + const updated = updateAurContents(input); + assert.doesNotThrow(() => verifyAurContents({ ...input, ...updated })); + assert.match(updated.srcinfo, /openwork-linux-x64-9\.8\.7\.tar\.gz/); + assert.match(updated.srcinfo, /openwork-linux-arm64-9\.8\.7\.tar\.gz/); + + assert.deepEqual(updateAurContents({ ...input, ...updated }), updated); + assert.throws(() => verifyAurContents({ + ...input, + ...updated, + srcinfo: updated.srcinfo.replace(input.shaArm64, "0".repeat(64)), + }), /\.SRCINFO/); + assert.throws(() => verifyAurContents({ + ...input, + ...updated, + srcinfo: updated.srcinfo.replace("different-ai/openwork", "attacker/fork"), + }), /\.SRCINFO/); +}); + +test("AUR host trust requires every official pinned fingerprint", () => { + const records = Object.entries(AUR_HOST_FINGERPRINTS).map(([type, fingerprint]) => ({ type, fingerprint })); + assert.doesNotThrow(() => validateHostFingerprints(records)); + assert.throws(() => validateHostFingerprints(records.slice(1)), /Missing pinned/); + assert.throws(() => validateHostFingerprints([ + { ...records[0], fingerprint: "SHA256:wrong" }, + ...records.slice(1), + ]), /mismatch/); +}); diff --git a/scripts/aur/open-pr.sh b/scripts/aur/open-pr.sh deleted file mode 100755 index 0fd93a734b..0000000000 --- a/scripts/aur/open-pr.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) - -TAG="${1:-${RELEASE_TAG:-}}" -if [ -z "$TAG" ]; then - echo "Missing release tag (arg or RELEASE_TAG)." >&2 - exit 1 -fi - -if [[ "$TAG" != v* ]]; then - TAG="v${TAG}" -fi - -VERSION="${TAG#v}" -"${ROOT_DIR}/scripts/aur/update-aur.sh" "$TAG" - -cd "$ROOT_DIR" - -if ! git status --porcelain -- packaging/aur/PKGBUILD packaging/aur/.SRCINFO | grep -q .; then - echo "AUR packaging already up to date." - exit 0 -fi - -BRANCH="chore/aur-${VERSION}" -git switch -c "$BRANCH" 2>/dev/null || git switch "$BRANCH" - -git add packaging/aur/PKGBUILD packaging/aur/.SRCINFO -git -c user.name="OpenWork Release Bot" \ - -c user.email="release-bot@users.noreply.github.com" \ - commit -m "chore(aur): update PKGBUILD for ${VERSION}" - -git push --set-upstream origin "$BRANCH" - -if gh pr list --head "$BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then - echo "PR already open for ${BRANCH}." - exit 0 -fi - -gh pr create --title "chore(aur): update PKGBUILD for ${VERSION}" --base dev --body "$(cat <<'EOF' -## Summary -- Update AUR PKGBUILD and .SRCINFO for the ${VERSION} release -- Refresh sha256 for the Linux .deb release asset -EOF -)" diff --git a/scripts/aur/pin-host-keys.mjs b/scripts/aur/pin-host-keys.mjs new file mode 100755 index 0000000000..17fd1ffa85 --- /dev/null +++ b/scripts/aur/pin-host-keys.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { appendFileSync, readFileSync } from "node:fs"; + +export const AUR_HOST_FINGERPRINTS = { + "ssh-ed25519": "SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4", + "ecdsa-sha2-nistp256": "SHA256:uTa/0PndEgPZTf76e1DFqXKJEXKsn7m9ivhLQtzGOCI", + "ssh-rsa": "SHA256:5s5cIyReIfNNVGRFdDbe3hdYiI5OelHGpw2rOUud3Q8", +}; + +export function validateHostFingerprints(records) { + const seen = new Set(); + for (const record of records) { + const expected = AUR_HOST_FINGERPRINTS[record.type]; + if (!expected) throw new Error(`Unexpected AUR host key type: ${record.type}`); + if (record.fingerprint !== expected) throw new Error(`AUR ${record.type} host fingerprint mismatch.`); + if (seen.has(record.type)) throw new Error(`Duplicate AUR host key type: ${record.type}`); + seen.add(record.type); + } + const missing = Object.keys(AUR_HOST_FINGERPRINTS).filter((type) => !seen.has(type)); + if (missing.length > 0) throw new Error(`Missing pinned AUR host key types: ${missing.join(", ")}`); +} + +function fingerprint(line) { + const result = spawnSync("ssh-keygen", ["-lf", "-", "-E", "sha256"], { + input: `${line}\n`, + encoding: "utf8", + }); + if (result.status !== 0) throw new Error(`Could not fingerprint AUR host key: ${result.stderr}`); + const match = result.stdout.match(/\b(SHA256:[A-Za-z0-9+/]+)\b/); + if (!match) throw new Error("ssh-keygen did not return a SHA-256 fingerprint."); + return match[1]; +} + +function main() { + const [scanPath, knownHostsPath] = process.argv.slice(2); + if (!scanPath || !knownHostsPath) throw new Error("Usage: pin-host-keys.mjs "); + const lines = readFileSync(scanPath, "utf8").split(/\r?\n/) + .filter((line) => line && !line.startsWith("#")); + const records = lines.map((line) => ({ + type: line.split(/\s+/)[1], + fingerprint: fingerprint(line), + })); + validateHostFingerprints(records); + appendFileSync(knownHostsPath, `${lines.join("\n")}\n`); +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/aur/publish-aur.sh b/scripts/aur/publish-aur.sh index 9363eae360..fb71772663 100755 --- a/scripts/aur/publish-aur.sh +++ b/scripts/aur/publish-aur.sh @@ -35,14 +35,13 @@ KEY_PATH="${TMP_DIR}/aur.key" printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$KEY_PATH" chmod 600 "$KEY_PATH" -mkdir -p "$HOME/.ssh" -touch "$HOME/.ssh/known_hosts" +SCAN_PATH="${TMP_DIR}/aur-host-keys" +KNOWN_HOSTS_PATH="${TMP_DIR}/known_hosts" +ssh-keyscan -t rsa,ecdsa,ed25519 aur.archlinux.org > "$SCAN_PATH" 2>/dev/null +node "${ROOT_DIR}/scripts/aur/pin-host-keys.mjs" "$SCAN_PATH" "$KNOWN_HOSTS_PATH" +chmod 600 "$KNOWN_HOSTS_PATH" -if ! ssh-keygen -F aur.archlinux.org >/dev/null 2>&1; then - ssh-keyscan -t rsa,ecdsa,ed25519 aur.archlinux.org >> "$HOME/.ssh/known_hosts" 2>/dev/null -fi - -export GIT_SSH_COMMAND="ssh -i $KEY_PATH -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" +export GIT_SSH_COMMAND="ssh -i $KEY_PATH -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$KNOWN_HOSTS_PATH" git clone "$AUR_REMOTE" "$TMP_DIR/aur" diff --git a/scripts/aur/update-aur.sh b/scripts/aur/update-aur.sh index 03070a8eb0..185b515e98 100755 --- a/scripts/aur/update-aur.sh +++ b/scripts/aur/update-aur.sh @@ -2,105 +2,42 @@ set -euo pipefail ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) -PKG_DIR="${ROOT_DIR}/packaging/aur" -PKGBUILD="${PKG_DIR}/PKGBUILD" -SRCINFO="${PKG_DIR}/.SRCINFO" - -PYTHON_BIN="${PYTHON_BIN:-}" -if [ -z "$PYTHON_BIN" ]; then - if command -v python3 >/dev/null 2>&1; then - PYTHON_BIN="python3" - elif command -v python >/dev/null 2>&1; then - PYTHON_BIN="python" - else - echo "Python is required (python3 preferred)." >&2 - exit 1 - fi -fi - TAG="${1:-${RELEASE_TAG:-}}" +ASSETS_DIR="${AUR_ASSETS_DIR:-}" + if [ -z "$TAG" ]; then echo "Missing release tag (arg or RELEASE_TAG)." >&2 exit 1 fi - if [[ "$TAG" != v* ]]; then TAG="v${TAG}" fi +if [ "${2:-}" = "--assets-dir" ]; then + ASSETS_DIR="${3:-}" +fi VERSION="${TAG#v}" -ASSET_NAME_AMD64="${AUR_ASSET_NAME:-openwork-linux-x64-${VERSION}.tar.gz}" -ASSET_NAME_ARM64="openwork-linux-arm64-${VERSION}.tar.gz" -ASSET_URL_AMD64="https://github.com/different-ai/openwork/releases/download/${TAG}/${ASSET_NAME_AMD64}" -ASSET_URL_ARM64="https://github.com/different-ai/openwork/releases/download/${TAG}/${ASSET_NAME_ARM64}" - -TMP_DIR=$(mktemp -d) -trap 'rm -rf "$TMP_DIR"' EXIT - -curl -fsSL -o "${TMP_DIR}/${ASSET_NAME_AMD64}" "$ASSET_URL_AMD64" -curl -fsSL -o "${TMP_DIR}/${ASSET_NAME_ARM64}" "$ASSET_URL_ARM64" - -# Calculate SHA256 checksums -SHA256_AMD64=$(sha256sum "${TMP_DIR}/${ASSET_NAME_AMD64}" | awk '{print $1}') -SHA256_ARM64=$(sha256sum "${TMP_DIR}/${ASSET_NAME_ARM64}" | awk '{print $1}') - -$PYTHON_BIN - "$PKGBUILD" "$VERSION" "$SHA256_AMD64" "$SHA256_ARM64" <<'PY' -import pathlib -import re -import sys - -path = pathlib.Path(sys.argv[1]) -version = sys.argv[2] -sha_amd64 = sys.argv[3] -sha_arm64 = sys.argv[4] - -text = path.read_text() -text = re.sub(r"^pkgver=.*$", f"pkgver={version}", text, flags=re.M) -text = re.sub(r"^(pkgrel=)\d+", r"\g<1>1", text, flags=re.M) -text = re.sub(r"^sha256sums_x86_64=.*$", f"sha256sums_x86_64=('{sha_amd64}')", text, flags=re.M) -text = re.sub(r"^sha256sums_aarch64=.*$", f"sha256sums_aarch64=('{sha_arm64}')", text, flags=re.M) -path.write_text(text) -PY - -$PYTHON_BIN - "$SRCINFO" "$PKGBUILD" "$VERSION" "$SHA256_AMD64" "$SHA256_ARM64" "$ASSET_URL_AMD64" "$ASSET_URL_ARM64" <<'PY' -import pathlib -import re -import sys - -srcinfo_path = pathlib.Path(sys.argv[1]) -pkgbuild_path = pathlib.Path(sys.argv[2]) -version = sys.argv[3] -sha_amd64 = sys.argv[4] -sha_arm64 = sys.argv[5] -url_amd64 = sys.argv[6] -url_arm64 = sys.argv[7] - -pkgbuild = pkgbuild_path.read_text() -match = re.search(r"^pkgname=(.+)$", pkgbuild, flags=re.M) -if not match: - raise SystemExit("Could not determine pkgname from PKGBUILD") -pkgname = match.group(1).strip() - -renamed_amd64 = f"{pkgname}-{version}-x64.tar.gz" -renamed_arm64 = f"{pkgname}-{version}-arm64.tar.gz" +X64_NAME="openwork-linux-x64-${VERSION}.tar.gz" +ARM64_NAME="openwork-linux-arm64-${VERSION}.tar.gz" +TMP_DIR="" + +if [ -z "$ASSETS_DIR" ]; then + TMP_DIR=$(mktemp -d) + trap 'rm -rf "$TMP_DIR"' EXIT + ASSETS_DIR="$TMP_DIR" + curl -fsSL --retry 5 --retry-all-errors \ + -o "${ASSETS_DIR}/${X64_NAME}" \ + "https://github.com/${GITHUB_REPOSITORY:-different-ai/openwork}/releases/download/${TAG}/${X64_NAME}" + curl -fsSL --retry 5 --retry-all-errors \ + -o "${ASSETS_DIR}/${ARM64_NAME}" \ + "https://github.com/${GITHUB_REPOSITORY:-different-ai/openwork}/releases/download/${TAG}/${ARM64_NAME}" +fi -text = srcinfo_path.read_text() -text = re.sub(r"^\s*pkgver = .*", f"\tpkgver = {version}", text, flags=re.M) -text = re.sub(r"^\s*pkgrel = .*", "\tpkgrel = 1", text, flags=re.M) -text = re.sub(r"^\s*noextract = .*\n?", "", text, flags=re.M) -text = re.sub( - r"^\s*source_x86_64 = .*", - f"\tsource_x86_64 = {renamed_amd64}::{url_amd64}", - text, - flags=re.M, -) -text = re.sub(r"^\s*sha256sums_x86_64 = .*", f"\tsha256sums_x86_64 = {sha_amd64}", text, flags=re.M) -text = re.sub( - r"^\s*source_aarch64 = .*", - f"\tsource_aarch64 = {renamed_arm64}::{url_arm64}", - text, - flags=re.M, -) -text = re.sub(r"^\s*sha256sums_aarch64 = .*", f"\tsha256sums_aarch64 = {sha_arm64}", text, flags=re.M) -srcinfo_path.write_text(text) -PY +node "${ROOT_DIR}/scripts/aur/aur-packaging.mjs" update \ + --tag "$TAG" \ + --x64 "${ASSETS_DIR}/${X64_NAME}" \ + --arm64 "${ASSETS_DIR}/${ARM64_NAME}" +node "${ROOT_DIR}/scripts/aur/aur-packaging.mjs" verify \ + --tag "$TAG" \ + --x64 "${ASSETS_DIR}/${X64_NAME}" \ + --arm64 "${ASSETS_DIR}/${ARM64_NAME}" diff --git a/scripts/aur/verify-aur-assets.sh b/scripts/aur/verify-aur-assets.sh new file mode 100755 index 0000000000..4d5d501d13 --- /dev/null +++ b/scripts/aur/verify-aur-assets.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +TAG="${1:-${RELEASE_TAG:-}}" + +if [ -z "$TAG" ]; then + echo "Missing release tag (arg or RELEASE_TAG)." >&2 + exit 1 +fi +if [[ "$TAG" != v* ]]; then + TAG="v${TAG}" +fi + +VERSION="${TAG#v}" +X64_NAME="openwork-linux-x64-${VERSION}.tar.gz" +ARM64_NAME="openwork-linux-arm64-${VERSION}.tar.gz" +TMP_DIR=$(mktemp -d) +trap 'rm -rf "$TMP_DIR"' EXIT + +for asset in "$X64_NAME" "$ARM64_NAME"; do + curl -fsSL --retry 5 --retry-all-errors \ + -o "${TMP_DIR}/${asset}" \ + "https://github.com/${GITHUB_REPOSITORY:-different-ai/openwork}/releases/download/${TAG}/${asset}" +done + +node "${ROOT_DIR}/scripts/aur/aur-packaging.mjs" verify \ + --tag "$TAG" \ + --x64 "${TMP_DIR}/${X64_NAME}" \ + --arm64 "${TMP_DIR}/${ARM64_NAME}" diff --git a/scripts/release/artifact-contract.mjs b/scripts/release/artifact-contract.mjs new file mode 100755 index 0000000000..3f7f6a907c --- /dev/null +++ b/scripts/release/artifact-contract.mjs @@ -0,0 +1,310 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, join, relative, resolve } from "node:path"; +import { parse as parseYaml } from "yaml"; + +const SHA = /^[0-9a-f]{40}$/; +const SHA256 = /^[0-9a-f]{64}$/; +const VERSION = /^\d+\.\d+\.\d+$/; +const ALLOWED_ASSET = /\.(?:AppImage|blockmap|dmg|exe|rpm|zip)$/i; + +const distributions = [ + { key: "public", id: "", prefix: "openwork", channel: "latest" }, + { key: "cloud", id: "-cloud", prefix: "openwork-cloud", channel: "cloud" }, + { key: "enterprise", id: "-enterprise", prefix: "openwork-enterprise", channel: "enterprise" }, +]; +const platforms = [ + { id: "macos-arm64", os: "mac", arch: "arm64", extensions: ["dmg", "zip"], manifest: "latest-mac.yml" }, + { id: "macos-x64", os: "mac", arch: "x64", extensions: ["dmg", "zip"], manifest: "latest-mac.yml" }, + { id: "linux-x64", os: "linux", arch: "x64", extensions: ["AppImage", "tar.gz"], manifest: "latest-linux.yml" }, + { id: "linux-arm64", os: "linux", arch: "arm64", extensions: ["AppImage", "tar.gz"], manifest: "latest-linux-arm64.yml" }, + { id: "windows-x64", os: "win", arch: "x64", extensions: ["exe"], manifest: "latest.yml" }, + { id: "windows-arm64", os: "win", arch: "arm64", extensions: ["exe"], manifest: "latest.yml" }, +]; + +export const RELEASE_STAGES = distributions.flatMap((distribution) => + platforms.map((platform) => ({ + distribution: distribution.key, + prefix: distribution.prefix, + channel: distribution.channel, + ...platform, + id: `electron${distribution.id}-${platform.id}`, + manifest: platform.manifest.replace(/^latest/, distribution.channel), + })), +); + +export const EXPECTED_MERGED_MANIFESTS = [...new Set(RELEASE_STAGES.map((stage) => stage.manifest))].sort(); + +export function matrixArtifactNames(runAttempt) { + requireNumeric(runAttempt, "run attempt"); + return RELEASE_STAGES.map((stage) => `release-desktop-${stage.id}-attempt-${runAttempt}`).sort(); +} + +export function mergedManifestArtifactName(runAttempt) { + requireNumeric(runAttempt, "run attempt"); + return `release-desktop-manifests-attempt-${runAttempt}`; +} + +export function artifactIndexArtifactName(runAttempt) { + requireNumeric(runAttempt, "run attempt"); + return `release-artifact-index-attempt-${runAttempt}`; +} + +function requireNumeric(value, label) { + if (!/^\d+$/.test(String(value))) throw new Error(`${label} must be numeric.`); +} + +function hash(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function filesUnder(directory) { + return readdirSync(directory).flatMap((entry) => { + const path = join(directory, entry); + return statSync(path).isDirectory() ? filesUnder(path) : [path]; + }); +} + +function stageFor(id) { + const stage = RELEASE_STAGES.find((candidate) => candidate.id === id); + if (!stage) throw new Error(`Unexpected desktop stage: ${id}`); + return stage; +} + +function expectedAssetNames(stage, version) { + const base = `${stage.prefix}-${stage.os}-${stage.arch}-${version}`; + return stage.extensions.map((extension) => `${base}.${extension}`); +} + +function validateAssetName(stage, name) { + const isTar = name.endsWith(".tar.gz"); + if (!isTar && !ALLOWED_ASSET.test(name)) throw new Error(`Unexpected release file: ${name}`); + if (!name.startsWith(`${stage.prefix}-`)) throw new Error(`${stage.id} contains foreign asset ${name}.`); + if (stage.distribution === "public" && /^openwork-(?:cloud|enterprise)-/.test(name)) { + throw new Error(`${stage.id} contains non-public asset ${name}.`); + } +} + +export function createStageMetadata({ directory, stageId, version, sourceSha, runId, runAttempt, artifactName }) { + const stage = stageFor(stageId); + if (!VERSION.test(version)) throw new Error(`Invalid release version: ${version}`); + if (!SHA.test(sourceSha)) throw new Error(`Invalid build source SHA: ${sourceSha}`); + requireNumeric(runId, "run ID"); + requireNumeric(runAttempt, "run attempt"); + const expectedArtifactName = `release-desktop-${stage.id}-attempt-${runAttempt}`; + if (artifactName !== expectedArtifactName) { + throw new Error(`${stage.id} artifact must be ${expectedArtifactName}, got ${artifactName}.`); + } + + const assetsDirectory = join(directory, "assets"); + const manifestsDirectory = join(directory, "manifests"); + const expectedEntries = existsSync(join(directory, "stage.json")) + ? ["assets", "manifests", "stage.json"] + : ["assets", "manifests"]; + const entries = readdirSync(directory).sort(); + if (JSON.stringify(entries) !== JSON.stringify(expectedEntries)) { + throw new Error(`${stage.id} contains unexpected staging entries: ${entries.join(", ")}`); + } + const assetPaths = filesUnder(assetsDirectory); + const manifestPaths = filesUnder(manifestsDirectory); + for (const path of assetPaths) { + if (relative(assetsDirectory, path) !== basename(path)) throw new Error(`${stage.id} contains nested asset staging data.`); + } + for (const path of manifestPaths) { + if (relative(manifestsDirectory, path) !== basename(path)) throw new Error(`${stage.id} contains nested manifest staging data.`); + } + const assetNames = assetPaths.map((path) => basename(path)).sort(); + const manifestNames = manifestPaths.map((path) => basename(path)).sort(); + if (new Set(assetNames).size !== assetNames.length) throw new Error(`${stage.id} has duplicate asset names.`); + if (manifestNames.length !== 1 || manifestNames[0] !== stage.manifest) { + throw new Error(`${stage.id} must contain only updater manifest ${stage.manifest}.`); + } + for (const name of assetNames) validateAssetName(stage, name); + for (const name of expectedAssetNames(stage, version)) { + if (!assetNames.includes(name)) throw new Error(`${stage.id} is missing required asset ${name}.`); + } + + const files = [...assetPaths, ...manifestPaths].map((path) => ({ + path: relative(directory, path).replaceAll("\\", "/"), + name: basename(path), + sha256: hash(path), + size: statSync(path).size, + })).sort((left, right) => left.path.localeCompare(right.path)); + return { + schema: 1, + stageId, + artifactName, + version, + sourceSha, + runId: String(runId), + runAttempt: String(runAttempt), + files, + }; +} + +function validateStageDirectory(directory, expected) { + const metadataPath = join(directory, "stage.json"); + const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); + const rebuilt = createStageMetadata({ + directory, + stageId: metadata.stageId, + version: expected.version, + sourceSha: expected.sourceSha, + runId: expected.runId, + runAttempt: expected.runAttempt, + artifactName: basename(directory), + }); + if (JSON.stringify(metadata) !== JSON.stringify(rebuilt)) { + throw new Error(`${basename(directory)} stage metadata or bytes do not match.`); + } + return rebuilt; +} + +function readMergedManifests(directory, version, assetNames) { + const paths = filesUnder(directory); + const names = paths.map((path) => basename(path)).sort(); + if (JSON.stringify(names) !== JSON.stringify(EXPECTED_MERGED_MANIFESTS)) { + throw new Error(`Merged updater manifests are incomplete or unexpected: ${names.join(", ")}`); + } + return paths.map((path) => { + const manifest = parseYaml(readFileSync(path, "utf8")); + if (manifest.version !== version || !Array.isArray(manifest.files) || manifest.files.length === 0) { + throw new Error(`${basename(path)} has invalid version or files.`); + } + for (const file of manifest.files) { + if (!file || typeof file.url !== "string" || !assetNames.has(file.url)) { + throw new Error(`${basename(path)} references missing staged asset ${file?.url ?? "?"}.`); + } + } + return { + name: basename(path), + sha256: hash(path), + size: statSync(path).size, + }; + }).sort((left, right) => left.name.localeCompare(right.name)); +} + +export function buildArtifactIndex({ root, manifestsDirectory, version, sourceSha, runId, runAttempt }) { + if (!VERSION.test(version) || !SHA.test(sourceSha)) throw new Error("Invalid artifact index release identity."); + requireNumeric(runId, "run ID"); + requireNumeric(runAttempt, "run attempt"); + const expectedNames = matrixArtifactNames(runAttempt); + const rootEntries = readdirSync(root); + if (rootEntries.some((entry) => !statSync(join(root, entry)).isDirectory())) { + throw new Error("Desktop stage root contains unexpected files."); + } + const relativeManifests = relative(root, manifestsDirectory).replaceAll("\\", "/"); + const allowedExtra = relativeManifests.startsWith("../") ? "" : relativeManifests.split("/")[0]; + const unexpectedDirectories = rootEntries.filter((entry) => + !entry.startsWith("release-desktop-electron-") && entry !== allowedExtra); + if (unexpectedDirectories.length > 0) { + throw new Error(`Desktop stage root contains unexpected directories: ${unexpectedDirectories.join(", ")}`); + } + const directories = rootEntries.filter((entry) => entry.startsWith("release-desktop-electron-")).sort(); + if (JSON.stringify(directories) !== JSON.stringify(expectedNames)) { + throw new Error(`Desktop stage set is incomplete or unexpected: ${directories.join(", ")}`); + } + const expected = { version, sourceSha, runId: String(runId), runAttempt: String(runAttempt) }; + const stages = directories.map((name) => validateStageDirectory(join(root, name), expected)); + const assets = new Map(); + for (const stage of stages) { + for (const file of stage.files.filter((candidate) => candidate.path.startsWith("assets/"))) { + if (assets.has(file.name)) throw new Error(`Duplicate staged publication asset: ${file.name}`); + assets.set(file.name, { name: file.name, sha256: file.sha256, size: file.size }); + } + } + const mergedManifests = readMergedManifests(manifestsDirectory, version, new Set(assets.keys())); + return { + schema: 1, + version, + sourceSha, + runId: String(runId), + runAttempt: String(runAttempt), + stages, + publicationFiles: [...assets.values(), ...mergedManifests] + .sort((left, right) => left.name.localeCompare(right.name)), + }; +} + +export function validateArtifactIndex(index) { + if (index.schema !== 1 || !VERSION.test(index.version) || !SHA.test(index.sourceSha)) { + throw new Error("Invalid artifact index identity."); + } + requireNumeric(index.runId, "artifact index run ID"); + requireNumeric(index.runAttempt, "artifact index run attempt"); + if (!Array.isArray(index.stages) || index.stages.length !== RELEASE_STAGES.length) { + throw new Error(`Artifact index must contain exactly ${RELEASE_STAGES.length} stages.`); + } + const names = index.stages.map((stage) => stage.artifactName).sort(); + if (JSON.stringify(names) !== JSON.stringify(matrixArtifactNames(index.runAttempt))) { + throw new Error("Artifact index stage names do not match the recorded attempt."); + } + for (const stage of index.stages) { + if (stage.sourceSha !== index.sourceSha || stage.runId !== index.runId || stage.runAttempt !== index.runAttempt) { + throw new Error(`Mixed source or attempt in ${stage.artifactName}.`); + } + } + const publicationNames = index.publicationFiles.map((file) => file.name); + if (new Set(publicationNames).size !== publicationNames.length + || index.publicationFiles.some((file) => !SHA256.test(file.sha256))) { + throw new Error("Artifact index publication files are duplicate or invalid."); + } + return index; +} + +function flag(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : ""; +} + +function writeJson(path, value) { + mkdirSync(resolve(path, ".."), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function main() { + const command = process.argv[2]; + if (command === "stage") { + const directory = resolve(flag("--directory")); + const metadata = createStageMetadata({ + directory, + stageId: flag("--stage-id"), + version: flag("--version"), + sourceSha: flag("--source-sha"), + runId: flag("--run-id"), + runAttempt: flag("--run-attempt"), + artifactName: flag("--artifact-name"), + }); + writeJson(join(directory, "stage.json"), metadata); + } else if (command === "index") { + const index = buildArtifactIndex({ + root: resolve(flag("--root")), + manifestsDirectory: resolve(flag("--manifests")), + version: flag("--version"), + sourceSha: flag("--source-sha"), + runId: flag("--run-id"), + runAttempt: flag("--run-attempt"), + }); + validateArtifactIndex(index); + writeJson(resolve(flag("--output")), index); + } else { + throw new Error("Usage: artifact-contract.mjs stage|index [options]"); + } +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/release/composable-release.test.mjs b/scripts/release/composable-release.test.mjs new file mode 100644 index 0000000000..a96477ada6 --- /dev/null +++ b/scripts/release/composable-release.test.mjs @@ -0,0 +1,267 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; +import { parse as parseYaml } from "yaml"; +import { + EXPECTED_MERGED_MANIFESTS, + RELEASE_STAGES, + buildArtifactIndex, + createStageMetadata, + matrixArtifactNames, + validateArtifactIndex, +} from "./artifact-contract.mjs"; +import { + createReleaseMetadata, + decodeReleaseBody, + safeReleaseMetadataOutputs, + validateReleaseMetadata, + validateReleaseTree, +} from "./release-metadata.mjs"; +import { createReleasePlan, decideTagAction } from "./release-plan.mjs"; +import { decideNpmPublication } from "./npm-publication.mjs"; +import { collectStagedAssets, planImmutablePublication } from "./staged-assets.mjs"; + +const root = resolve(import.meta.dirname, "../.."); +const workflowPath = (name) => resolve(root, ".github/workflows", name); +const workflow = (name) => readFileSync(workflowPath(name), "utf8"); +const version = "1.2.4"; +const sourceSha = "a".repeat(40); +const runId = "1234"; +const runAttempt = "1"; + +function writeJson(path, value) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function assetName(stage, extension) { + return `${stage.prefix}-${stage.os}-${stage.arch}-${version}.${extension}`; +} + +function createCompleteStageFixture() { + const directory = mkdtempSync(join(tmpdir(), "openwork-stage-contract-")); + const stageRoot = join(directory, "stages"); + const manifests = join(directory, "merged-manifests"); + mkdirSync(stageRoot); + mkdirSync(manifests); + const manifestAssets = new Map(EXPECTED_MERGED_MANIFESTS.map((name) => [name, []])); + + for (const stage of RELEASE_STAGES) { + const artifactName = `release-desktop-${stage.id}-attempt-${runAttempt}`; + const artifactDirectory = join(stageRoot, artifactName); + mkdirSync(join(artifactDirectory, "assets"), { recursive: true }); + mkdirSync(join(artifactDirectory, "manifests"), { recursive: true }); + for (const extension of stage.extensions) { + writeFileSync(join(artifactDirectory, "assets", assetName(stage, extension)), `${stage.id}-${extension}`); + } + writeFileSync(join(artifactDirectory, "manifests", stage.manifest), `version: ${version}\n`); + manifestAssets.get(stage.manifest).push(assetName(stage, stage.extensions[0])); + writeJson(join(artifactDirectory, "stage.json"), createStageMetadata({ + directory: artifactDirectory, + stageId: stage.id, + version, + sourceSha, + runId, + runAttempt, + artifactName, + })); + } + for (const [name, assets] of manifestAssets) { + writeFileSync(join(manifests, name), [ + `version: ${version}`, + "files:", + ...assets.map((name) => ` - url: ${name}`), + "", + ].join("\n")); + } + return { directory, stageRoot, manifests }; +} + +function buildFixtureIndex(fixture) { + return buildArtifactIndex({ + root: fixture.stageRoot, + manifestsDirectory: fixture.manifests, + version, + sourceSha, + runId, + runAttempt, + }); +} + +test("plans patch, minor, and major releases deterministically", () => { + assert.deepEqual(createReleasePlan({ currentVersion: "1.2.3", bump: "patch" }), { + version, + tag: `v${version}`, + branch: `release/v${version}`, + }); + assert.equal(createReleasePlan({ currentVersion: "1.2.3", bump: "minor" }).version, "1.3.0"); + assert.equal(createReleasePlan({ currentVersion: "1.2.3", bump: "major" }).version, "2.0.0"); + assert.throws(() => createReleasePlan({ currentVersion: "1.2.3-alpha.1", bump: "patch" })); +}); + +test("tag creation is idempotent only at the exact merge SHA", () => { + const sha = "a".repeat(40); + assert.equal(decideTagAction("", sha), "create"); + assert.equal(decideTagAction(sha, sha), "keep"); + assert.throws(() => decideTagAction("b".repeat(40), sha), /already targets/); +}); + +test("builds an exact complete 18-stage immutable artifact index", () => { + const fixture = createCompleteStageFixture(); + try { + const index = validateArtifactIndex(buildFixtureIndex(fixture)); + assert.equal(index.stages.length, 18); + assert.deepEqual(index.stages.map((stage) => stage.artifactName).sort(), matrixArtifactNames(runAttempt)); + assert.equal(index.publicationFiles.filter((file) => file.name.endsWith(".yml")).length, 12); + assert.equal(index.sourceSha, sourceSha); + assert.equal(index.runAttempt, runAttempt); + } finally { + rmSync(fixture.directory, { recursive: true, force: true }); + } +}); + +test("rejects missing, unexpected, mixed-source, and mixed-attempt desktop stages", () => { + for (const mutation of ["missing", "unexpected", "source", "attempt"]) { + const fixture = createCompleteStageFixture(); + try { + const first = matrixArtifactNames(runAttempt)[0]; + if (mutation === "missing") rmSync(join(fixture.stageRoot, first), { recursive: true }); + if (mutation === "unexpected") mkdirSync(join(fixture.stageRoot, "release-desktop-electron-unknown-attempt-1")); + if (mutation === "source" || mutation === "attempt") { + const path = join(fixture.stageRoot, first, "stage.json"); + const metadata = JSON.parse(readFileSync(path, "utf8")); + if (mutation === "source") metadata.sourceSha = "b".repeat(40); + if (mutation === "attempt") metadata.runAttempt = "2"; + writeJson(path, metadata); + } + assert.throws(() => buildFixtureIndex(fixture), /incomplete|unexpected|metadata|attempt|source/i, mutation); + } finally { + rmSync(fixture.directory, { recursive: true, force: true }); + } + } +}); + +test("committed release metadata validates index identity and keeps multiline body output safe", () => { + const fixture = createCompleteStageFixture(); + try { + const indexPath = join(fixture.directory, "artifacts.json"); + const metadataPath = join(fixture.directory, "release.json"); + writeJson(indexPath, buildFixtureIndex(fixture)); + const body = "Line one\nline two%0A\nname=value"; + const metadata = createReleaseMetadata({ + version, + prepareRunId: runId, + prepareRunAttempt: runAttempt, + buildSourceSha: sourceSha, + releaseName: `OpenWork v${version}`, + releaseBody: body, + prerelease: false, + notarize: true, + signWindows: false, + publishServer: true, + publishSnapshot: true, + indexPath, + }); + writeJson(metadataPath, metadata); + const validated = validateReleaseTree({ + metadataPath, + indexPath, + expectedTag: `v${version}`, + expectedBranch: `release/v${version}`, + }); + assert.equal(decodeReleaseBody(validated.metadata.release.bodyBase64), body); + assert.ok(Object.values(safeReleaseMetadataOutputs(metadata)).every((value) => !/[\r\n]/.test(value))); + assert.throws(() => validateReleaseMetadata({ ...metadata, branch: "release/v9.9.9" }), /branch/); + writeFileSync(indexPath, `${readFileSync(indexPath, "utf8")} `); + assert.throws(() => validateReleaseTree({ metadataPath, indexPath }), /SHA-256/); + } finally { + rmSync(fixture.directory, { recursive: true, force: true }); + } +}); + +test("npm publication distinguishes exact version, confirmed 404, and registry failures", () => { + assert.equal(decideNpmPublication({ version, status: 0, stdout: `"${version}"`, stderr: "" }), "keep"); + assert.equal(decideNpmPublication({ version, status: 1, stdout: "", stderr: "ERR_PNPM_FETCH_404 404 Not Found" }), "publish"); + assert.throws(() => decideNpmPublication({ version, status: 1, stdout: "", stderr: "ETIMEDOUT" }), /without a confirmed 404/); + assert.throws(() => decideNpmPublication({ version, status: 0, stdout: '"1.2.3"', stderr: "" }), /expected exact/); +}); + +test("immutable asset publication keeps matching bytes and rejects replacements", () => { + const directory = mkdtempSync(join(tmpdir(), "openwork-release-assets-")); + try { + const assetDirectory = join(directory, "release-desktop-electron-linux-x64-attempt-1", "assets"); + const manifestDirectory = join(directory, "release-desktop-manifests-attempt-1"); + mkdirSync(assetDirectory, { recursive: true }); + mkdirSync(manifestDirectory, { recursive: true }); + writeFileSync(join(assetDirectory, `openwork-linux-x64-${version}.tar.gz`), "linux"); + writeFileSync(join(manifestDirectory, "latest-linux.yml"), `version: ${version}\n`); + const staged = collectStagedAssets(directory); + const existing = new Map(staged.map((asset) => [asset.name, { sha256: asset.sha256 }])); + assert.deepEqual(planImmutablePublication(staged, existing).map((item) => item.action), ["keep", "keep"]); + existing.set(staged[0].name, { sha256: "0".repeat(64) }); + assert.throws(() => planImmutablePublication(staged, existing), /differs from staged/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("workflows parse and enforce App-authored one-review immutable contracts", () => { + const names = [ + "release-prepare.yml", + "release-macos-aarch64.yml", + "release-continue.yml", + "release-publish-desktop.yml", + "release-publish-server.yml", + "release-daytona-snapshot.yml", + "release-publish-aur.yml", + "aur-validate.yml", + ]; + const parsed = new Map(names.map((name) => [name, parseYaml(workflow(name))])); + for (const name of names) assert.ok(parsed.get(name).jobs, `${name} has jobs`); + const prepareText = workflow("release-prepare.yml"); + assert.deepEqual(parsed.get("release-prepare.yml").on.workflow_dispatch.inputs.bump.options, ["patch", "minor", "major"]); + assert.match(prepareText, /actions\/create-github-app-token@v2/); + assert.match(prepareText, /GH_TOKEN: \$\{\{ steps\.release-app-pr\.outputs\.token \}\}/); + assert.match(prepareText, /RELEASE_APP_LOGIN/); + assert.match(prepareText, /\.github\/releases\/\$TAG\/release\.json/); + assert.match(prepareText, /verify-signed-release-branch\.sh/); + assert.ok((prepareText.match(/persist-credentials: false/g) ?? []).length >= 2); + assert.match(prepareText, /RELEASE_APP_TOKEN: \$\{\{ steps\.release-app-preflight\.outputs\.token \}\}/); + assert.match(prepareText, /RELEASE_APP_TOKEN: \$\{\{ steps\.release-app-branch\.outputs\.token \}\}/); + assert.equal((prepareText.match(/git push "\$remote" "HEAD:refs\/heads\/\$BRANCH"/g) ?? []).length, 2); + assert.doesNotMatch(prepareText, /git push origin "HEAD:refs\/heads/); + const signatureVerifier = readFileSync(resolve(root, "scripts/release/verify-signed-release-branch.sh"), "utf8"); + assert.match(signatureVerifier, /gpg\.ssh\.allowedSignersFile/); + assert.match(signatureVerifier, /verify-commit/); + assert.doesNotMatch(prepareText, /openwork-release:|--failed|git push origin dev/); + + const desktopStage = parsed.get("release-macos-aarch64.yml"); + assert.equal(desktopStage.jobs["stage-electron"].strategy.matrix.include.length, 18); + assert.equal(desktopStage.on.push, undefined); + assert.match(workflow("release-macos-aarch64.yml"), /ref: \$\{\{ inputs\.source_sha \}\}/); + assert.match(workflow("release-macos-aarch64.yml"), /attempt-\$\{\{ github\.run_attempt \}\}/); + + const continuationText = workflow("release-continue.yml"); + assert.match(continuationText, /resolve-merge\.mjs/); + assert.match(continuationText, /RELEASE_APP_LOGIN/); + assert.match(continuationText, /permission-contents: write/); + assert.match(continuationText, /verify-signed-release-branch\.sh "\$SOURCE_SHA" "\$pr_head_sha"/); + assert.match(continuationText, /git diff --quiet "\$PR_HEAD\^\{tree\}" "\$MERGE_SHA\^\{tree\}"/); + assert.doesNotMatch(continuationText, /pull_request\.body|--force-with-lease|push --delete/); + + const desktopPublish = workflow("release-publish-desktop.yml"); + assert.match(desktopPublish, /steps\.metadata\.outputs\.prepare_run_attempt/); + assert.match(desktopPublish, /Rebuild and verify complete artifact contract/); + assert.match(desktopPublish, /actions\/runs\/\$PREPARE_RUN_ID\/attempts\/\$PREPARE_RUN_ATTEMPT/); + assert.match(desktopPublish, /returned_attempt/); + assert.doesNotMatch(desktopPublish, /--clobber|inputs\.prepare_run_id/); + assert.match(workflow("release-publish-server.yml"), /openwork-server@\$version/); + assert.match(workflow("release-publish-aur.yml"), /verify-aur-assets\.sh/); + assert.doesNotMatch(workflow("release-publish-aur.yml"), /gh pr create/); + assert.doesNotMatch(workflow("aur-validate.yml"), /AUR_SSH_PRIVATE_KEY|Publish to AUR/); + for (const name of ["release-publish-desktop.yml", "release-publish-server.yml", "release-publish-aur.yml"]) { + assert.match(workflow(name), /not verified/); + assert.doesNotMatch(workflow(name), /echo "- Output:/); + } +}); diff --git a/scripts/release/npm-publication.mjs b/scripts/release/npm-publication.mjs new file mode 100755 index 0000000000..c112b1bd31 --- /dev/null +++ b/scripts/release/npm-publication.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; + +const VERSION = /^\d+\.\d+\.\d+$/; + +export function decideNpmPublication({ version, status, stdout, stderr }) { + if (!VERSION.test(version)) throw new Error(`Invalid npm release version: ${version}`); + if (status === 0) { + let published; + try { + published = JSON.parse(stdout); + } catch { + throw new Error("npm registry returned invalid JSON."); + } + if (published !== version) throw new Error(`npm registry returned ${published}, expected exact openwork-server@${version}.`); + return "keep"; + } + if (/\bE404\b|ERR_PNPM_FETCH_404|404[^\n]*Not Found|Not Found[^\n]*404|is not in this registry/i.test(stderr)) return "publish"; + throw new Error(`npm registry lookup failed without a confirmed 404: ${stderr.trim() || `exit ${status}`}`); +} + +function flag(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : ""; +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + try { + const decision = decideNpmPublication({ + version: flag("--version"), + status: Number(flag("--status")), + stdout: readFileSync(flag("--stdout"), "utf8"), + stderr: readFileSync(flag("--stderr"), "utf8"), + }); + process.stdout.write(`${decision}\n`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/release/prepare.mjs b/scripts/release/prepare.mjs old mode 100644 new mode 100755 index 964378ab8a..89bf411d52 --- a/scripts/release/prepare.mjs +++ b/scripts/release/prepare.mjs @@ -1,118 +1,44 @@ #!/usr/bin/env node -/** - * release:prepare [patch|minor|major] - * - * Bumps versions, runs lockfile check, runs release:review, - * commits, and tags — but does NOT push. - * - * Flags: - * --dry-run Print what would happen without mutating anything. - * --ci Skip interactive-safety checks (branch, clean-tree). - */ -import { execSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { execFileSync } from "node:child_process"; -const root = resolve(fileURLToPath(new URL("../..", import.meta.url))); const args = process.argv.slice(2); - -const dryRun = args.includes("--dry-run"); -const ci = args.includes("--ci"); -const bumpType = args.find((a) => ["patch", "minor", "major"].includes(a)) ?? "patch"; - -const log = (msg) => console.log(` ${msg}`); -const heading = (msg) => console.log(`\n▸ ${msg}`); -const success = (msg) => console.log(` ✓ ${msg}`); -const fail = (msg) => { - console.error(` ✗ ${msg}`); - process.exit(1); -}; - -const run = (cmd, opts = {}) => { - if (dryRun && !opts.readOnly) { - log(`[dry-run] ${cmd}`); - return ""; - } - try { - return execSync(cmd, { cwd: root, encoding: "utf8", stdio: opts.stdio ?? "pipe" }).trim(); - } catch (err) { - if (opts.allowFail) return ""; - fail(`Command failed: ${cmd}\n${err.stderr || err.message}`); - } -}; - -// ── Step 1: Verify state ──────────────────────────────────────────── -heading("Checking git state"); - -if (!ci) { - const branch = run("git rev-parse --abbrev-ref HEAD", { readOnly: true }); - if (branch !== "dev") fail(`Must be on 'dev' branch (currently on '${branch}')`); - success(`On branch ${branch}`); -} - -const dirty = run("git status --porcelain", { readOnly: true }); -if (dirty && !ci) fail(`Working tree is dirty:\n${dirty}`); -success("Working tree clean"); - -heading("Syncing with origin/dev"); -run("git fetch origin dev", { readOnly: true }); -const behind = run("git rev-list HEAD..origin/dev --count", { readOnly: true }); -if (behind !== "0" && !dryRun) { - log(`Behind origin/dev by ${behind} commits — pulling…`); - run("git pull --rebase origin dev"); +const bump = args.find((argument) => ["patch", "minor", "major"].includes(argument)) ?? "patch"; +const invalid = args.filter((argument) => !["patch", "minor", "major", "--", "--dry-run", "--watch"].includes(argument)); +const repository = process.env.GITHUB_REPOSITORY ?? "different-ai/openwork"; +const workflow = "release-prepare.yml"; + +if (invalid.length > 0) { + console.error(`Unknown release argument: ${invalid.join(" ")}`); + process.exit(2); } -success("Up to date with origin/dev"); - -// ── Step 2: Bump versions ─────────────────────────────────────────── -heading(`Bumping versions (${bumpType})`); -const bumpOutput = run(`pnpm bump:${bumpType}`, { stdio: "pipe" }); -if (!dryRun) { - log(bumpOutput); -} - -// Read the new version -const appPkg = JSON.parse(readFileSync(resolve(root, "apps/app/package.json"), "utf8")); -const version = appPkg.version; -success(`Version is now ${version}`); - -// ── Step 3: Lockfile ──────────────────────────────────────────────── -heading("Checking lockfile"); -run("pnpm install --lockfile-only"); -const lockfileChanged = run("git diff --name-only -- pnpm-lock.yaml", { readOnly: true }); -if (lockfileChanged) { - success("Lockfile updated"); -} else { - success("Lockfile unchanged"); -} - -// ── Step 4: Release review ────────────────────────────────────────── -heading("Running release review"); -const reviewOutput = run("node scripts/release/review.mjs --strict", { readOnly: true, allowFail: false }); -log(reviewOutput); -success("Release review passed"); - -// ── Step 5: Commit ────────────────────────────────────────────────── -heading("Committing version bump"); -run("git add -A"); -run(`git commit -m "chore: bump version to ${version}"`); -success(`Committed: chore: bump version to ${version}`); - -// ── Step 6: Tag ───────────────────────────────────────────────────── -heading("Creating tag"); -const tag = `v${version}`; -run(`git tag ${tag}`); -success(`Tagged ${tag}`); -// ── Summary ───────────────────────────────────────────────────────── -console.log("\n" + "─".repeat(50)); -console.log(` Release prepared: ${tag}`); -console.log(` Version: ${version}`); -console.log(` Bump type: ${bumpType}`); -if (dryRun) { - console.log(" Mode: DRY RUN (nothing was changed)"); +const command = [ + "workflow", "run", workflow, + "--repo", repository, + "--ref", "dev", + "--field", `bump=${bump}`, +]; + +console.log(`Preparing a ${bump} release through GitHub Actions.`); +console.log(`gh ${command.join(" ")}`); +if (args.includes("--dry-run")) process.exit(0); + +execFileSync("gh", command, { stdio: "inherit" }); +console.log(`Dashboard: https://github.com/${repository}/actions/workflows/${workflow}`); + +if (args.includes("--watch")) { + console.log("Waiting for the prepare run to appear..."); + execFileSync("sleep", ["5"]); + const runId = execFileSync("gh", [ + "run", "list", + "--repo", repository, + "--workflow", workflow, + "--event", "workflow_dispatch", + "--branch", "dev", + "--limit", "1", + "--json", "databaseId", + "--jq", ".[0].databaseId", + ], { encoding: "utf8" }).trim(); + if (!runId) throw new Error("Could not resolve the prepare workflow run ID."); + execFileSync("gh", ["run", "watch", runId, "--repo", repository, "--exit-status"], { stdio: "inherit" }); } -console.log(""); -console.log(" Next step:"); -console.log(` pnpm release:ship`); -console.log("─".repeat(50) + "\n"); diff --git a/scripts/release/publish-electron-assets.mjs b/scripts/release/publish-electron-assets.mjs index f8bcc3e485..ccd34a8c42 100644 --- a/scripts/release/publish-electron-assets.mjs +++ b/scripts/release/publish-electron-assets.mjs @@ -1,26 +1,19 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { basename, dirname, extname, join, resolve } from "node:path"; const args = process.argv.slice(2); -const manifestsOnly = args.includes("--manifests-only"); -const positional = args.filter((arg) => arg !== "--manifests-only"); -const [distRootArg, releaseTag] = positional; +const prepareManifests = args.includes("--prepare-manifests"); +const positional = args.filter((arg) => arg !== "--prepare-manifests"); +const [distRootArg, outputDirArg] = positional; -if (!distRootArg || !releaseTag) { - console.error("Usage: node scripts/release/publish-electron-assets.mjs [--manifests-only] "); - process.exit(2); -} - -const repo = process.env.GITHUB_REPOSITORY; -if (!repo) { - console.error("GITHUB_REPOSITORY is required."); +if (!prepareManifests || !distRootArg || !outputDirArg) { + console.error("Usage: node scripts/release/publish-electron-assets.mjs --prepare-manifests "); process.exit(2); } const distRoot = resolve(distRootArg); -const outputDir = resolve(process.env.RUNNER_TEMP || ".", "openwork-electron-manifests"); +const outputDir = resolve(outputDirArg); mkdirSync(outputDir, { recursive: true }); function walk(dir) { @@ -38,17 +31,6 @@ function isUpdaterManifest(path) { return /^(?:latest|cloud|enterprise).*\.ya?ml$/.test(basename(path)); } -function isReleaseAsset(path) { - if (isUpdaterManifest(path)) return false; - if (!basename(path).startsWith("openwork-")) return false; - return /\.(AppImage|blockmap|dmg|exe|rpm|zip)$/i.test(path) || /\.tar\.gz$/i.test(path); -} - -function runGh(args) { - const result = spawnSync("gh", args, { stdio: "inherit", encoding: "utf8" }); - if (result.status !== 0) process.exit(result.status ?? 1); -} - function parseManifest(path) { const raw = readFileSync(path, "utf8"); const parsed = { files: [] }; @@ -200,7 +182,6 @@ function validateManifest(name, manifest) { } const files = walk(distRoot); -const releaseAssets = files.filter(isReleaseAsset); const manifestsByName = new Map(); for (const path of files.filter(isUpdaterManifest)) { @@ -210,25 +191,16 @@ for (const path of files.filter(isUpdaterManifest)) { manifestsByName.set(name, current); } -if (!manifestsOnly && releaseAssets.length === 0) { - console.error(`No Electron release assets found under ${distRoot}`); - process.exit(1); -} - if (manifestsByName.size === 0) { console.error(`No Electron updater manifests found under ${distRoot}`); process.exit(1); } -if (!manifestsOnly) { - runGh(["release", "upload", releaseTag, ...releaseAssets, "--repo", repo, "--clobber"]); -} - for (const [name, paths] of [...manifestsByName.entries()].sort()) { const manifest = mergeManifests(name, paths); validateManifest(name, manifest); const outputPath = join(outputDir, name); mkdirSync(dirname(outputPath), { recursive: true }); writeFileSync(outputPath, stringifyManifest(manifest), "utf8"); - runGh(["release", "upload", releaseTag, `${outputPath}#${name}`, "--repo", repo, "--clobber"]); + console.log(outputPath); } diff --git a/scripts/release/release-metadata.mjs b/scripts/release/release-metadata.mjs new file mode 100755 index 0000000000..121a5282c3 --- /dev/null +++ b/scripts/release/release-metadata.mjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + artifactIndexArtifactName, + matrixArtifactNames, + mergedManifestArtifactName, + validateArtifactIndex, +} from "./artifact-contract.mjs"; + +const VERSION = /^\d+\.\d+\.\d+$/; +const SHA = /^[0-9a-f]{40}$/; +const SHA256 = /^[0-9a-f]{64}$/; + +function exactKeys(value, keys, label) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`); + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`${label} has unexpected or missing fields.`); +} + +function numeric(value, label) { + if (!/^\d+$/.test(String(value))) throw new Error(`${label} must be numeric.`); + return String(value); +} + +export function releaseMetadataDirectory(tag) { + return `.github/releases/${tag}`; +} + +export function releaseMetadataPath(tag) { + return `${releaseMetadataDirectory(tag)}/release.json`; +} + +export function releaseArtifactIndexPath(tag) { + return `${releaseMetadataDirectory(tag)}/artifacts.json`; +} + +export function encodeReleaseBody(body) { + return Buffer.from(body, "utf8").toString("base64"); +} + +export function decodeReleaseBody(encoded) { + if (typeof encoded !== "string" || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) { + throw new Error("Release body is not valid base64."); + } + const decoded = Buffer.from(encoded, "base64"); + if (decoded.toString("base64") !== encoded) throw new Error("Release body base64 is not canonical."); + return decoded.toString("utf8"); +} + +export function sha256File(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +export function validateReleaseMetadata(metadata, expected = {}) { + exactKeys(metadata, ["schema", "version", "tag", "branch", "prepareRunId", "prepareRunAttempt", "buildSourceSha", "release", "artifacts"], "Release metadata"); + if (metadata.schema !== 1 || !VERSION.test(metadata.version)) throw new Error("Invalid release metadata schema or version."); + if (metadata.tag !== `v${metadata.version}` || metadata.branch !== `release/${metadata.tag}`) { + throw new Error("Release metadata tag and branch do not match its version."); + } + if (!SHA.test(metadata.buildSourceSha)) throw new Error("Release metadata build source SHA is invalid."); + metadata.prepareRunId = numeric(metadata.prepareRunId, "prepare run ID"); + metadata.prepareRunAttempt = numeric(metadata.prepareRunAttempt, "prepare run attempt"); + + exactKeys(metadata.release, ["name", "bodyBase64", "prerelease", "notarize", "signWindows", "publishServer", "publishSnapshot"], "Release options"); + if (typeof metadata.release.name !== "string" || metadata.release.name.length === 0 || /[\r\n]/.test(metadata.release.name)) { + throw new Error("Release name must be a non-empty single line."); + } + decodeReleaseBody(metadata.release.bodyBase64); + for (const field of ["prerelease", "notarize", "signWindows", "publishServer", "publishSnapshot"]) { + if (typeof metadata.release[field] !== "boolean") throw new Error(`Release option ${field} must be boolean.`); + } + + exactKeys(metadata.artifacts, ["indexPath", "indexArtifactName", "indexSha256", "mergedManifestsArtifactName", "matrixArtifactNames"], "Artifact identity"); + if (metadata.artifacts.indexPath !== releaseArtifactIndexPath(metadata.tag) + || metadata.artifacts.indexArtifactName !== artifactIndexArtifactName(metadata.prepareRunAttempt) + || metadata.artifacts.mergedManifestsArtifactName !== mergedManifestArtifactName(metadata.prepareRunAttempt) + || !SHA256.test(metadata.artifacts.indexSha256) + || JSON.stringify(metadata.artifacts.matrixArtifactNames) !== JSON.stringify(matrixArtifactNames(metadata.prepareRunAttempt))) { + throw new Error("Release artifact identity is invalid."); + } + if (expected.tag && metadata.tag !== expected.tag) throw new Error(`Metadata tag ${metadata.tag} does not match ${expected.tag}.`); + if (expected.branch && metadata.branch !== expected.branch) throw new Error(`Metadata branch ${metadata.branch} does not match ${expected.branch}.`); + return metadata; +} + +export function createReleaseMetadata({ version, prepareRunId, prepareRunAttempt, buildSourceSha, releaseName, releaseBody, prerelease, notarize, signWindows, publishServer, publishSnapshot, indexPath }) { + const tag = `v${version}`; + const attempt = String(prepareRunAttempt); + return validateReleaseMetadata({ + schema: 1, + version, + tag, + branch: `release/${tag}`, + prepareRunId: String(prepareRunId), + prepareRunAttempt: attempt, + buildSourceSha, + release: { + name: releaseName, + bodyBase64: encodeReleaseBody(releaseBody), + prerelease, + notarize, + signWindows, + publishServer, + publishSnapshot, + }, + artifacts: { + indexPath: releaseArtifactIndexPath(tag), + indexArtifactName: artifactIndexArtifactName(attempt), + indexSha256: sha256File(indexPath), + mergedManifestsArtifactName: mergedManifestArtifactName(attempt), + matrixArtifactNames: matrixArtifactNames(attempt), + }, + }); +} + +export function validateReleaseTree({ metadataPath, indexPath, expectedTag, expectedBranch }) { + const metadata = validateReleaseMetadata(JSON.parse(readFileSync(metadataPath, "utf8")), { + tag: expectedTag, + branch: expectedBranch, + }); + if (sha256File(indexPath) !== metadata.artifacts.indexSha256) throw new Error("Committed artifact index SHA-256 does not match release metadata."); + const index = validateArtifactIndex(JSON.parse(readFileSync(indexPath, "utf8"))); + if (index.version !== metadata.version || index.sourceSha !== metadata.buildSourceSha + || index.runId !== metadata.prepareRunId || index.runAttempt !== metadata.prepareRunAttempt) { + throw new Error("Committed artifact index identity does not match release metadata."); + } + return { metadata, index }; +} + +function bool(value) { + return value === "true"; +} + +export function safeReleaseMetadataOutputs(metadata) { + const values = { + tag: metadata.tag, + version: metadata.version, + branch: metadata.branch, + prepare_run_id: metadata.prepareRunId, + prepare_run_attempt: metadata.prepareRunAttempt, + build_source_sha: metadata.buildSourceSha, + release_name: metadata.release.name, + release_body_base64: metadata.release.bodyBase64, + prerelease: String(metadata.release.prerelease), + publish_server: String(metadata.release.publishServer), + publish_snapshot: String(metadata.release.publishSnapshot), + index_path: metadata.artifacts.indexPath, + index_artifact_name: metadata.artifacts.indexArtifactName, + merged_manifests_artifact_name: metadata.artifacts.mergedManifestsArtifactName, + }; + if (Object.values(values).some((value) => /[\r\n]/.test(value))) { + throw new Error("Release metadata output contains an unsafe newline."); + } + return values; +} + +function appendOutputs(metadata, output) { + const values = safeReleaseMetadataOutputs(metadata); + for (const [key, value] of Object.entries(values)) appendFileSync(output, `${key}=${value}\n`); +} + +function main() { + const command = process.argv[2]; + if (command === "write") { + const outputPath = resolve(process.argv[3]); + const indexPath = resolve(process.argv[4]); + const metadata = createReleaseMetadata({ + version: process.env.VERSION, + prepareRunId: process.env.GITHUB_RUN_ID, + prepareRunAttempt: process.env.GITHUB_RUN_ATTEMPT, + buildSourceSha: process.env.BUILD_SOURCE_SHA, + releaseName: process.env.RELEASE_NAME, + releaseBody: process.env.RELEASE_BODY, + prerelease: bool(process.env.INPUT_PRERELEASE), + notarize: bool(process.env.INPUT_NOTARIZE), + signWindows: bool(process.env.INPUT_SIGN_WINDOWS), + publishServer: bool(process.env.INPUT_PUBLISH_SERVER), + publishSnapshot: bool(process.env.INPUT_PUBLISH_SNAPSHOT), + indexPath, + }); + mkdirSync(resolve(outputPath, ".."), { recursive: true }); + writeFileSync(outputPath, `${JSON.stringify(metadata, null, 2)}\n`); + } else if (command === "outputs") { + const metadataPath = resolve(process.argv[3]); + const indexPath = resolve(process.argv[4]); + const { metadata } = validateReleaseTree({ + metadataPath, + indexPath, + expectedTag: process.env.EXPECTED_TAG, + expectedBranch: process.env.EXPECTED_BRANCH, + }); + appendOutputs(metadata, process.env.GITHUB_OUTPUT); + } else { + throw new Error("Usage: release-metadata.mjs write|outputs "); + } +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/release/release-plan.mjs b/scripts/release/release-plan.mjs new file mode 100755 index 0000000000..ae3e06fdfd --- /dev/null +++ b/scripts/release/release-plan.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +const STABLE_VERSION = /^(\d+)\.(\d+)\.(\d+)$/; + +export function bumpVersion(currentVersion, bump) { + const match = currentVersion.match(STABLE_VERSION); + if (!match) throw new Error(`Invalid stable version: ${currentVersion}`); + if (!["patch", "minor", "major"].includes(bump)) { + throw new Error(`Invalid release bump: ${bump}`); + } + + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]); + if (bump === "major") return `${major + 1}.0.0`; + if (bump === "minor") return `${major}.${minor + 1}.0`; + return `${major}.${minor}.${patch + 1}`; +} + +export function createReleasePlan({ currentVersion, bump }) { + const version = bumpVersion(currentVersion, bump); + return { + version, + tag: `v${version}`, + branch: `release/v${version}`, + }; +} + +export function decideTagAction(existingSha, targetSha) { + if (!/^[0-9a-f]{40}$/.test(targetSha)) { + throw new Error(`Invalid target commit SHA: ${targetSha}`); + } + if (!existingSha) return "create"; + if (existingSha === targetSha) return "keep"; + throw new Error(`Tag already targets ${existingSha}, not ${targetSha}.`); +} + +function readFlag(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : ""; +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + try { + const plan = createReleasePlan({ + currentVersion: readFlag("--current"), + bump: readFlag("--bump"), + }); + process.stdout.write(`${JSON.stringify(plan)}\n`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/release/resolve-merge.mjs b/scripts/release/resolve-merge.mjs new file mode 100755 index 0000000000..8a1cad5895 --- /dev/null +++ b/scripts/release/resolve-merge.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +import { appendFileSync, readFileSync } from "node:fs"; + +const TAG = /^v\d+\.\d+\.\d+$/; +const SHA = /^[0-9a-f]{40}$/; +const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); +const eventName = process.env.GITHUB_EVENT_NAME; +const repository = process.env.GITHUB_REPOSITORY; +const appLogin = process.env.RELEASE_APP_LOGIN; + +if (!/^[A-Za-z0-9-]+\[bot\]$/.test(appLogin)) { + throw new Error("Required repository variable RELEASE_APP_LOGIN is missing or invalid."); +} + +function validatePullRequest(pullRequest, tag) { + const branch = `release/${tag}`; + if (!pullRequest.merged_at && !pullRequest.merged) throw new Error("Release pull request is not merged."); + if (pullRequest.user?.login !== appLogin) throw new Error(`Release PR author must be ${appLogin}.`); + if (pullRequest.head?.repo?.full_name !== repository || pullRequest.head?.ref !== branch) { + throw new Error("Release PR head repository or branch is not trusted."); + } + if (pullRequest.base?.ref !== "dev") throw new Error("Release PR base must be dev."); + const mergeSha = pullRequest.merge_commit_sha; + if (!SHA.test(mergeSha)) throw new Error("Release PR merge commit SHA is invalid."); + if (!/^\d+$/.test(String(pullRequest.number))) throw new Error("Release PR number is invalid."); + const expectedUrl = `https://github.com/${repository}/pull/${pullRequest.number}`; + if (pullRequest.html_url !== expectedUrl) throw new Error("Release PR URL is invalid."); + return { + prNumber: String(pullRequest.number), + tag, + branch, + mergeSha, + prUrl: pullRequest.html_url, + }; +} + +let resolved; +if (eventName === "pull_request_target") { + const branch = event.pull_request?.head?.ref ?? ""; + const tag = branch.startsWith("release/") ? branch.slice("release/".length) : ""; + if (!TAG.test(tag)) throw new Error(`Release PR branch is invalid: ${branch}`); + resolved = validatePullRequest(event.pull_request, tag); +} else if (eventName === "workflow_dispatch") { + const inputTag = process.env.INPUT_TAG; + const tag = inputTag.startsWith("v") ? inputTag : `v${inputTag}`; + if (!TAG.test(tag)) throw new Error(`Invalid release tag: ${tag}`); + const pages = JSON.parse(readFileSync(process.env.RELEASE_PR_CANDIDATES_PATH, "utf8")); + const candidates = pages.flat().filter((pullRequest) => + pullRequest.merged_at + && pullRequest.user?.login === appLogin + && pullRequest.head?.repo?.full_name === repository + && pullRequest.head?.ref === `release/${tag}` + && pullRequest.base?.ref === "dev"); + if (candidates.length !== 1) throw new Error(`Expected one trusted merged release PR for ${tag}, found ${candidates.length}.`); + resolved = validatePullRequest(candidates[0], tag); +} else { + throw new Error(`Unsupported continuation event: ${eventName}`); +} + +for (const [key, value] of Object.entries({ + pr_number: resolved.prNumber, + tag: resolved.tag, + branch: resolved.branch, + merge_sha: resolved.mergeSha, + pr_url: resolved.prUrl, +})) { + appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`); +} diff --git a/scripts/release/ship.mjs b/scripts/release/ship.mjs deleted file mode 100644 index 00d1654e2b..0000000000 --- a/scripts/release/ship.mjs +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env node -/** - * release:ship - * - * Pushes the current tag + dev branch to origin, then prints the - * GitHub Actions workflow URL. Optionally tails the workflow run. - * - * Flags: - * --dry-run Print what would happen without pushing. - * --watch Tail the GHA workflow run after push. - */ -import { execSync } from "node:child_process"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const root = resolve(fileURLToPath(new URL("../..", import.meta.url))); -const args = process.argv.slice(2); - -const dryRun = args.includes("--dry-run"); -const watch = args.includes("--watch"); - -const log = (msg) => console.log(` ${msg}`); -const heading = (msg) => console.log(`\n▸ ${msg}`); -const success = (msg) => console.log(` ✓ ${msg}`); -const fail = (msg) => { - console.error(` ✗ ${msg}`); - process.exit(1); -}; - -const run = (cmd, opts = {}) => { - if (dryRun && !opts.readOnly) { - log(`[dry-run] ${cmd}`); - return ""; - } - try { - return execSync(cmd, { - cwd: root, - encoding: "utf8", - stdio: opts.inherit ? "inherit" : "pipe", - }).trim(); - } catch (err) { - if (opts.allowFail) return ""; - fail(`Command failed: ${cmd}\n${err.stderr || err.message}`); - } -}; - -// ── Step 1: Resolve tag from HEAD ─────────────────────────────────── -heading("Resolving tag"); - -const tag = run("git describe --tags --exact-match HEAD", { - readOnly: true, - allowFail: true, -}); - -if (!tag) { - fail( - "HEAD is not tagged. Run 'pnpm release:prepare' first.\n" + - " (Expected a vX.Y.Z tag on HEAD)" - ); -} - -if (!/^v\d+\.\d+\.\d+/.test(tag)) { - fail(`Tag '${tag}' does not look like a release tag (expected vX.Y.Z)`); -} - -success(`Found tag: ${tag}`); - -// ── Step 2: Push tag ──────────────────────────────────────────────── -heading("Pushing tag to origin"); -run(`git push origin ${tag}`); -success(`Pushed ${tag}`); - -// ── Step 3: Push dev ──────────────────────────────────────────────── -heading("Pushing dev to origin"); -run("git push origin dev"); -success("Pushed dev"); - -// ── Step 4: Print workflow URL ────────────────────────────────────── -heading("GitHub Actions"); - -const repo = "different-ai/openwork"; -const url = `https://github.com/${repo}/actions/workflows/release-macos-aarch64.yml`; -log(`Workflow: ${url}`); -log(`Release: https://github.com/${repo}/releases/tag/${tag}`); - -// ── Step 5: Optionally watch ──────────────────────────────────────── -if (watch && !dryRun) { - heading("Watching workflow run"); - log("Waiting for workflow to appear…"); - - // Give GitHub a moment to register the run - execSync("sleep 10", { cwd: root }); - - try { - const runs = run( - `gh run list --repo ${repo} --workflow "Release App" --limit 1 --json databaseId,headBranch,event -q ".[0].databaseId"`, - { readOnly: true } - ); - if (runs) { - log(`Run ID: ${runs}`); - run(`gh run watch ${runs} --repo ${repo} --exit-status`, { inherit: true }); - } else { - log("Could not find the workflow run. Check the Actions tab manually."); - } - } catch { - log("Workflow watch exited (check status on GitHub)."); - } -} - -// ── Summary ───────────────────────────────────────────────────────── -console.log("\n" + "─".repeat(50)); -console.log(` Shipped: ${tag}`); -if (dryRun) { - console.log(" Mode: DRY RUN (nothing was pushed)"); -} -console.log("─".repeat(50) + "\n"); diff --git a/scripts/release/staged-assets.mjs b/scripts/release/staged-assets.mjs new file mode 100755 index 0000000000..9f785fc63c --- /dev/null +++ b/scripts/release/staged-assets.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, +} from "node:fs"; +import { basename, join, resolve } from "node:path"; + +function walk(directory) { + return readdirSync(directory).flatMap((entry) => { + const path = join(directory, entry); + return statSync(path).isDirectory() ? walk(path) : [path]; + }); +} + +export function sha256(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +export function collectStagedAssets(root) { + const selected = walk(root).filter((path) => { + const normalized = path.replaceAll("\\", "/"); + return normalized.includes("/assets/") + || (/\/release-desktop-manifests-attempt-\d+\//.test(normalized) + && /\.(?:yml|yaml)$/.test(path)); + }); + const assets = new Map(); + for (const path of selected) { + const name = basename(path); + if (assets.has(name)) throw new Error(`Duplicate staged release asset: ${name}`); + assets.set(name, { name, path, sha256: sha256(path) }); + } + if (assets.size === 0) throw new Error(`No staged desktop assets found under ${root}.`); + return [...assets.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +export function planImmutablePublication(stagedAssets, existingAssets) { + return stagedAssets.map((asset) => { + const existing = existingAssets.get(asset.name); + if (!existing) return { ...asset, action: "upload" }; + if (existing.sha256 !== asset.sha256) { + throw new Error(`Published asset ${asset.name} differs from staged SHA-256 ${asset.sha256}.`); + } + return { ...asset, action: "keep" }; + }); +} + +function runGh(argumentsList, options = {}) { + const result = spawnSync("gh", argumentsList, { + encoding: "utf8", + stdio: options.capture ? "pipe" : "inherit", + }); + if (result.status !== 0) { + throw new Error(`gh ${argumentsList.join(" ")} failed with exit code ${result.status}`); + } + return result.stdout?.trim() ?? ""; +} + +function readExistingAssets(tag, repository, destination) { + const release = JSON.parse(runGh([ + "api", `repos/${repository}/releases/tags/${tag}`, + ], { capture: true })); + const existing = new Map(); + for (const asset of release.assets) { + const name = asset.name; + if (asset.digest?.startsWith("sha256:")) { + existing.set(name, { name, sha256: asset.digest.slice("sha256:".length) }); + continue; + } + const assetDirectory = join(destination, name.replace(/[^A-Za-z0-9._-]/g, "_")); + mkdirSync(assetDirectory, { recursive: true }); + runGh(["release", "download", tag, "--repo", repository, "--pattern", name, "--dir", assetDirectory]); + const path = join(assetDirectory, name); + if (!existsSync(path)) throw new Error(`Could not download existing release asset ${name}.`); + existing.set(name, { name, sha256: sha256(path) }); + } + return existing; +} + +function main() { + const [rootArg, tag] = process.argv.slice(2); + const repository = process.env.GITHUB_REPOSITORY; + if (!rootArg || !tag || !repository) { + throw new Error("Usage: staged-assets.mjs (GITHUB_REPOSITORY required)"); + } + const root = resolve(rootArg); + const staged = collectStagedAssets(root); + const publicLinuxAssets = [ + `openwork-linux-x64-${tag.slice(1)}.tar.gz`, + `openwork-linux-arm64-${tag.slice(1)}.tar.gz`, + ]; + for (const name of publicLinuxAssets) { + if (!staged.some((asset) => asset.name === name)) { + throw new Error(`Required staged AUR asset is missing: ${name}`); + } + } + + const downloadRoot = resolve(process.env.RUNNER_TEMP ?? ".", "existing-release-assets"); + mkdirSync(downloadRoot, { recursive: true }); + const plan = planImmutablePublication(staged, readExistingAssets(tag, repository, downloadRoot)); + for (const item of plan) { + if (item.action === "upload") { + const uploadPath = resolve(process.env.RUNNER_TEMP ?? ".", "release-upload", item.name); + mkdirSync(resolve(uploadPath, ".."), { recursive: true }); + cpSync(item.path, uploadPath); + runGh(["release", "upload", tag, `${uploadPath}#${item.name}`, "--repo", repository]); + } + console.log(`${item.action}\t${item.sha256}\t${item.name}`); + } +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/release/verify-signed-release-branch.sh b/scripts/release/verify-signed-release-branch.sh new file mode 100755 index 0000000000..fc6b830981 --- /dev/null +++ b/scripts/release/verify-signed-release-branch.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +SOURCE_SHA="${1:-}" +HEAD_SHA="${2:-}" +TAG="${3:-}" +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) + +if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] || [[ ! "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "Signed branch verification requires source and head commit SHAs." >&2 + exit 1 +fi +if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Signed branch verification requires a stable tag." >&2 + exit 1 +fi +if [ -z "${COMMIT_SIGNING_KEY:-}" ]; then + echo "Missing required secret: COMMIT_SIGNING_KEY" >&2 + exit 1 +fi + +key_path="$RUNNER_TEMP/release-signing-key" +allowed_signers="$RUNNER_TEMP/release-allowed-signers" +trap 'rm -f "$key_path" "$allowed_signers"' EXIT +umask 077 +printf '%s\n' "$COMMIT_SIGNING_KEY" > "$key_path" +chmod 600 "$key_path" +public_key="$(ssh-keygen -y -f "$key_path")" +printf '%s %s\n' '11430621+benjaminshafii@users.noreply.github.com' "$public_key" > "$allowed_signers" + +git -C "$ROOT_DIR" config --local gpg.format ssh +git -C "$ROOT_DIR" config --local gpg.ssh.allowedSignersFile "$allowed_signers" +git -C "$ROOT_DIR" merge-base --is-ancestor "$SOURCE_SHA" "$HEAD_SHA" + +commits=("$SOURCE_SHA") +while IFS= read -r commit; do + if [ -n "$commit" ]; then commits+=("$commit"); fi +done < <(git -C "$ROOT_DIR" rev-list --reverse "$SOURCE_SHA..$HEAD_SHA") + +for commit in "${commits[@]}"; do + git -C "$ROOT_DIR" verify-commit "$commit" +done + +while IFS= read -r path; do + case "$path" in + packaging/aur/PKGBUILD|packaging/aur/.SRCINFO|.github/releases/"$TAG"/*) ;; + *) + echo "Unexpected post-build release branch change: $path" >&2 + exit 1 + ;; + esac +done < <(git -C "$ROOT_DIR" diff --name-only "$SOURCE_SHA..$HEAD_SHA")