diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml new file mode 100644 index 00000000..f5098eeb --- /dev/null +++ b/.github/workflows/nightly-release.yml @@ -0,0 +1,116 @@ +name: Nightly Release + +# Releases are built from `main` on a schedule instead of from a manually pushed +# tag: the job inspects commits since the latest stable release tag, and when +# there are any it creates the next patch tag and publishes that release. The +# tag-triggered Release workflow remains available as a manual fallback. +on: + schedule: + # 20:00 Asia/Shanghai == 12:00 UTC. + - cron: "0 12 * * *" + workflow_dispatch: + +permissions: + contents: read + +# Never let a nightly run overlap itself (a manually dispatched run and the +# scheduled run, or two slow runs). The newest run does not cancel one that is +# already publishing a release. +concurrency: + group: nightly-release + cancel-in-progress: false + +jobs: + plan: + name: Plan nightly release + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + changed: ${{ steps.plan.outputs.changed }} + version: ${{ steps.plan.outputs.version }} + tag: ${{ steps.plan.outputs.tag }} + reason: ${{ steps.plan.outputs.reason }} + commit_count: ${{ steps.plan.outputs.commit_count }} + commit_summary: ${{ steps.plan.outputs.commit_summary }} + head_sha: ${{ steps.plan.outputs.head_sha }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Decide whether main moved since the latest release + id: plan + run: | + set -euo pipefail + ./scripts/nightly-release-plan.sh . origin/main | tee plan.txt + while IFS='=' read -r key value; do + case "$key" in + changed | version | reason | commit_count | commit_summary | head_sha | latest_tag) + if [ "$key" = "version" ] && [ -n "$value" ]; then + value="v$value" + fi + echo "$key=$value" >> "$GITHUB_OUTPUT" + ;; + esac + done < plan.txt + rm -f plan.txt + + - name: Report decision + env: + CHANGED: ${{ steps.plan.outputs.changed }} + VERSION: ${{ steps.plan.outputs.version }} + COMMITS: ${{ steps.plan.outputs.commit_count }} + SUMMARY: ${{ steps.plan.outputs.commit_summary }} + REASON: ${{ steps.plan.outputs.reason }} + run: | + if [ "$CHANGED" = "true" ]; then + { + echo "### Nightly release: ${VERSION}" + echo "" + echo "- Commits since the last release: ${COMMITS}" + echo "- Newest commits: ${SUMMARY}" + } >> "$GITHUB_STEP_SUMMARY" + echo "Releasing ${VERSION} from ${COMMITS} new commit(s)." + else + { + echo "### Nightly release: skipped" + echo "" + echo "${REASON}" + } >> "$GITHUB_STEP_SUMMARY" + echo "Skipping nightly release: ${REASON}" + fi + + # Full repository checks + Swift tests before anything is tagged. + validate: + name: Release Candidate Tests + needs: plan + if: ${{ needs.plan.outputs.changed == 'true' }} + runs-on: macos-26 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run repository checks + run: bash ./scripts/ci-basic-checks.sh + + - name: Ensure Metal toolchain + run: xcodebuild -downloadComponent MetalToolchain + + - name: Run unit tests + run: swift test + + # Creates the tag on the current origin/main tip, then signs, verifies, and + # publishes through the same reusable pipeline the manual tag flow uses. + release: + name: Sign, Verify & Publish + needs: [plan, validate] + if: ${{ needs.plan.outputs.changed == 'true' }} + uses: ./.github/workflows/release-artifact.yml + with: + version: ${{ needs.plan.outputs.version }} + tag: ${{ needs.plan.outputs.tag }} + create_tag: true + secrets: inherit diff --git a/.github/workflows/release-artifact.yml b/.github/workflows/release-artifact.yml new file mode 100644 index 00000000..2d0977c4 --- /dev/null +++ b/.github/workflows/release-artifact.yml @@ -0,0 +1,278 @@ +# Reusable signing/build/verify/publish pipeline shared by the tag-triggered +# release workflow and the nightly release workflow. Keeping the steps here +# prevents the two entry points from drifting. +# +# The caller must provide the version and tag because a reusable workflow cannot +# create the tag it is releasing; see .github/workflows/release.yml and +# .github/workflows/nightly-release.yml. +name: Release Artifact + +on: + workflow_call: + inputs: + version: + description: "Numeric bundle version, e.g. 0.0.46 (validated by release-version.sh)" + required: true + type: string + tag: + description: "Release tag, e.g. v0.0.46 (must already exist for the tag entry point)" + required: true + type: string + create_tag: + description: "Create and push the tag before building (nightly entry point)" + required: false + type: boolean + default: false + require_ancestor: + description: "Fail unless the tag commit is an ancestor of origin/main" + required: false + type: boolean + default: true + +permissions: + contents: read + +jobs: + release: + name: Sign, Verify & Publish + runs-on: macos-26 + timeout-minutes: 75 + environment: production + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate version and tag + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + ./scripts/release-version.sh "$RELEASE_TAG" >/dev/null + expected="$(./scripts/release-version.sh "$RELEASE_TAG")" + if [ "$expected" != "$RELEASE_VERSION" ]; then + echo "Tag $RELEASE_TAG does not match version $RELEASE_VERSION" >&2 + exit 1 + fi + + - name: Require a SemVer tag on main + if: ${{ inputs.require_ancestor }} + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + git fetch origin main + if ! git merge-base --is-ancestor "$RELEASE_TAG^{commit}" origin/main; then + echo "Release commit $(git rev-parse "$RELEASE_TAG^{commit}") is not on origin/main" + exit 1 + fi + + - name: Create and push release tag + if: ${{ inputs.create_tag }} + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + git fetch origin main + if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then + echo "Tag $RELEASE_TAG already exists on origin; refusing to move it" + exit 1 + fi + tip="$(git rev-parse origin/main)" + if [ "$tip" != "$(git rev-parse HEAD)" ]; then + echo "Checked-out commit $(git rev-parse HEAD) is not the current origin/main tip $tip" + exit 1 + fi + git tag -a "$RELEASE_TAG" "$tip" -m "Utter $RELEASE_TAG" + git push origin "refs/tags/$RELEASE_TAG" + + - name: Require release credentials + env: + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + missing=() + for name in APPLE_CERTIFICATE_P12 APPLE_CERTIFICATE_PASSWORD; do + if [ -z "${!name:-}" ]; then + missing+=("$name") + fi + done + if [ ${#missing[@]} -ne 0 ]; then + echo "Missing protected release secrets: ${missing[*]}" + exit 1 + fi + + - name: Import signing certificate + env: + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + CERT_PATH="$RUNNER_TEMP/certificate.p12" + KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" + KEYCHAIN_PASS="$(openssl rand -hex 16)" + + echo "$APPLE_CERTIFICATE_P12" | base64 --decode > "$CERT_PATH" + security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN_PATH" + security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: \ + -k "$KEYCHAIN_PASS" "$KEYCHAIN_PATH" + + CERT_PEM="$RUNNER_TEMP/certificate.pem" + # OpenSSL 3 disables legacy RC2-40-CBC used by older PKCS#12 exports. + # Try modern decode first; fall back to -legacy for existing secrets. + if ! openssl pkcs12 -in "$CERT_PATH" -clcerts -nokeys \ + -passin "pass:${APPLE_CERTIFICATE_PASSWORD}" -out "$CERT_PEM" 2>/dev/null + then + openssl pkcs12 -in "$CERT_PATH" -clcerts -nokeys -legacy \ + -passin "pass:${APPLE_CERTIFICATE_PASSWORD}" -out "$CERT_PEM" + fi + SIGN_CERT_SHA256="$(openssl x509 -in "$CERT_PEM" -noout \ + -fingerprint -sha256 | cut -d= -f2 | tr -d ':')" + IDENTITY="$(security find-identity -p codesigning "$KEYCHAIN_PATH" \ + | sed -n 's/.*"\(.*\)".*/\1/p' \ + | head -1)" + if [ -z "$IDENTITY" ]; then + echo "No code-signing identity found in the release certificate" + exit 1 + fi + if [[ "$IDENTITY" != "Developer ID Application:"* ]]; then + sudo security add-trusted-cert -d -r trustRoot \ + -k "$KEYCHAIN_PATH" "$CERT_PEM" + fi + security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + rm -f "$CERT_PATH" "$CERT_PEM" + echo "SIGN_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" + echo "SIGN_CERT_SHA256=$SIGN_CERT_SHA256" >> "$GITHUB_ENV" + echo "Signing identity imported: $IDENTITY" + + - name: Ensure Metal toolchain + run: xcodebuild -downloadComponent MetalToolchain + + - name: Build signed app and DMG + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + ./scripts/build-app.sh \ + --version="$RELEASE_VERSION" \ + --sign="$SIGN_IDENTITY" + + - name: Classify and verify signed artifact + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + SIGNATURE="$(codesign -dvvv dist/Utter.app 2>&1)" + if ! grep -Fqx "Authority=$SIGN_IDENTITY" <<<"$SIGNATURE"; then + echo "Built app authority does not match imported identity: $SIGN_IDENTITY" + exit 1 + fi + + VERIFY_ARGS=( + --app dist/Utter.app + --dmg "dist/Utter-$RELEASE_VERSION.dmg" + --version "$RELEASE_VERSION" + --expected-cert-sha256 "$SIGN_CERT_SHA256" + ) + if grep -q '^Authority=Developer ID Application:' <<<"$SIGNATURE"; then + SIGNING_MODE=developer-id + VERIFY_ARGS+=(--require-developer-id) + elif grep -q '^TeamIdentifier=not set$' <<<"$SIGNATURE"; then + SIGNING_MODE=self-signed + VERIFY_ARGS+=(--require-self-signed) + else + echo "Unsupported non-Developer-ID signing identity" + exit 1 + fi + echo "SIGNING_MODE=$SIGNING_MODE" >> "$GITHUB_ENV" + echo "Signing mode: $SIGNING_MODE" | tee -a "$GITHUB_STEP_SUMMARY" + ./scripts/verify-release-artifact.sh "${VERIFY_ARGS[@]}" + + - name: Notarize and staple DMG + if: env.SIGNING_MODE == 'developer-id' + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + RELEASE_VERSION: ${{ inputs.version }} + run: | + missing=() + for name in APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD; do + if [ -z "${!name:-}" ]; then + missing+=("$name") + fi + done + if [ ${#missing[@]} -ne 0 ]; then + echo "Developer ID release is missing notarization secrets: ${missing[*]}" + exit 1 + fi + DMG="dist/Utter-$RELEASE_VERSION.dmg" + xcrun notarytool submit "$DMG" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_PASSWORD" \ + --wait --timeout 30m + xcrun stapler staple "$DMG" + + - name: Verify distribution and checksum + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + DMG="dist/Utter-$RELEASE_VERSION.dmg" + VERIFY_ARGS=( + --app dist/Utter.app + --dmg "$DMG" + --version "$RELEASE_VERSION" + --expected-cert-sha256 "$SIGN_CERT_SHA256" + ) + if [ "$SIGNING_MODE" = "developer-id" ]; then + VERIFY_ARGS+=(--require-developer-id --require-notarization) + else + VERIFY_ARGS+=(--require-self-signed) + fi + ./scripts/verify-release-artifact.sh "${VERIFY_ARGS[@]}" + ( + cd dist + shasum -a 256 "$(basename "$DMG")" > "$(basename "$DMG").sha256" + shasum -c "$(basename "$DMG").sha256" + ) + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + DMG="dist/Utter-$RELEASE_VERSION.dmg" + CHECKSUM="$DMG.sha256" + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + echo "Release $RELEASE_TAG already exists; refusing to replace immutable assets" + exit 1 + fi + if [ "$SIGNING_MODE" = "self-signed" ]; then + RELEASE_NOTE=$'> [!WARNING]\n> This release is signed with the project self-signed certificate and is not Apple-notarized. macOS may require manual approval before opening it.' + else + RELEASE_NOTE=$'> [!NOTE]\n> This release is signed with Apple Developer ID and notarized by Apple.' + fi + gh release create "$RELEASE_TAG" \ + --draft \ + --title "Utter $RELEASE_TAG" \ + --generate-notes \ + --notes "$RELEASE_NOTE" \ + --verify-tag + gh release upload "$RELEASE_TAG" "$DMG" "$CHECKSUM" + + DOWNLOAD_DIR="$(mktemp -d)" + trap 'rm -r "$DOWNLOAD_DIR"' EXIT + gh release download "$RELEASE_TAG" \ + --pattern "$(basename "$DMG")" \ + --pattern "$(basename "$CHECKSUM")" \ + --dir "$DOWNLOAD_DIR" + ( + cd "$DOWNLOAD_DIR" + shasum -c "$(basename "$CHECKSUM")" + ) + cmp "$DMG" "$DOWNLOAD_DIR/$(basename "$DMG")" + gh release edit "$RELEASE_TAG" --draft=false --latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11b879a0..b4fe8a60 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,205 +33,19 @@ jobs: - name: Ensure Metal toolchain run: xcodebuild -downloadComponent MetalToolchain - - name: Run unit tests - run: swift test - - release: - name: Sign, Verify & Publish - needs: validate - runs-on: macos-26 - timeout-minutes: 75 - environment: production - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Resolve version from tag id: version run: echo "value=$(./scripts/release-version.sh "$GITHUB_REF_NAME")" >> "$GITHUB_OUTPUT" - - name: Require release credentials - env: - APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - run: | - missing=() - for name in APPLE_CERTIFICATE_P12 APPLE_CERTIFICATE_PASSWORD; do - if [ -z "${!name:-}" ]; then - missing+=("$name") - fi - done - if [ ${#missing[@]} -ne 0 ]; then - echo "Missing protected release secrets: ${missing[*]}" - exit 1 - fi - - - name: Import signing certificate - env: - APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - run: | - CERT_PATH="$RUNNER_TEMP/certificate.p12" - KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" - KEYCHAIN_PASS="$(openssl rand -hex 16)" - - echo "$APPLE_CERTIFICATE_P12" | base64 --decode > "$CERT_PATH" - security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN_PATH" - security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" \ - -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" - security set-key-partition-list -S apple-tool:,apple: \ - -k "$KEYCHAIN_PASS" "$KEYCHAIN_PATH" - - CERT_PEM="$RUNNER_TEMP/certificate.pem" - # OpenSSL 3 disables legacy RC2-40-CBC used by older PKCS#12 exports. - # Try modern decode first; fall back to -legacy for existing secrets. - if ! openssl pkcs12 -in "$CERT_PATH" -clcerts -nokeys \ - -passin "pass:${APPLE_CERTIFICATE_PASSWORD}" -out "$CERT_PEM" 2>/dev/null - then - openssl pkcs12 -in "$CERT_PATH" -clcerts -nokeys -legacy \ - -passin "pass:${APPLE_CERTIFICATE_PASSWORD}" -out "$CERT_PEM" - fi - SIGN_CERT_SHA256="$(openssl x509 -in "$CERT_PEM" -noout \ - -fingerprint -sha256 | cut -d= -f2 | tr -d ':')" - IDENTITY="$(security find-identity -p codesigning "$KEYCHAIN_PATH" \ - | sed -n 's/.*"\(.*\)".*/\1/p' \ - | head -1)" - if [ -z "$IDENTITY" ]; then - echo "No code-signing identity found in the release certificate" - exit 1 - fi - if [[ "$IDENTITY" != "Developer ID Application:"* ]]; then - sudo security add-trusted-cert -d -r trustRoot \ - -k "$KEYCHAIN_PATH" "$CERT_PEM" - fi - security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db - rm -f "$CERT_PATH" "$CERT_PEM" - echo "SIGN_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" - echo "SIGN_CERT_SHA256=$SIGN_CERT_SHA256" >> "$GITHUB_ENV" - echo "Signing identity imported: $IDENTITY" - - - name: Ensure Metal toolchain - run: xcodebuild -downloadComponent MetalToolchain - - - name: Build signed app and DMG - run: | - ./scripts/build-app.sh \ - --version="${{ steps.version.outputs.value }}" \ - --sign="$SIGN_IDENTITY" - - - name: Classify and verify signed artifact - run: | - SIGNATURE="$(codesign -dvvv dist/Utter.app 2>&1)" - if ! grep -Fqx "Authority=$SIGN_IDENTITY" <<<"$SIGNATURE"; then - echo "Built app authority does not match imported identity: $SIGN_IDENTITY" - exit 1 - fi - - VERIFY_ARGS=( - --app dist/Utter.app - --dmg "dist/Utter-${{ steps.version.outputs.value }}.dmg" - --version "${{ steps.version.outputs.value }}" - --expected-cert-sha256 "$SIGN_CERT_SHA256" - ) - if grep -q '^Authority=Developer ID Application:' <<<"$SIGNATURE"; then - SIGNING_MODE=developer-id - VERIFY_ARGS+=(--require-developer-id) - elif grep -q '^TeamIdentifier=not set$' <<<"$SIGNATURE"; then - SIGNING_MODE=self-signed - VERIFY_ARGS+=(--require-self-signed) - else - echo "Unsupported non-Developer-ID signing identity" - exit 1 - fi - echo "SIGNING_MODE=$SIGNING_MODE" >> "$GITHUB_ENV" - echo "Signing mode: $SIGNING_MODE" | tee -a "$GITHUB_STEP_SUMMARY" - ./scripts/verify-release-artifact.sh "${VERIFY_ARGS[@]}" - - - name: Notarize and staple DMG - if: env.SIGNING_MODE == 'developer-id' - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} - run: | - missing=() - for name in APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD; do - if [ -z "${!name:-}" ]; then - missing+=("$name") - fi - done - if [ ${#missing[@]} -ne 0 ]; then - echo "Developer ID release is missing notarization secrets: ${missing[*]}" - exit 1 - fi - DMG="dist/Utter-${{ steps.version.outputs.value }}.dmg" - xcrun notarytool submit "$DMG" \ - --apple-id "$APPLE_ID" \ - --team-id "$APPLE_TEAM_ID" \ - --password "$APPLE_APP_PASSWORD" \ - --wait --timeout 30m - xcrun stapler staple "$DMG" - - - name: Verify distribution and checksum - run: | - DMG="dist/Utter-${{ steps.version.outputs.value }}.dmg" - VERIFY_ARGS=( - --app dist/Utter.app - --dmg "$DMG" - --version "${{ steps.version.outputs.value }}" - --expected-cert-sha256 "$SIGN_CERT_SHA256" - ) - if [ "$SIGNING_MODE" = "developer-id" ]; then - VERIFY_ARGS+=(--require-developer-id --require-notarization) - else - VERIFY_ARGS+=(--require-self-signed) - fi - ./scripts/verify-release-artifact.sh "${VERIFY_ARGS[@]}" - ( - cd dist - shasum -a 256 "$(basename "$DMG")" > "$(basename "$DMG").sha256" - shasum -c "$(basename "$DMG").sha256" - ) - - - name: Publish GitHub Release - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - TAG="$GITHUB_REF_NAME" - DMG="dist/Utter-${{ steps.version.outputs.value }}.dmg" - CHECKSUM="$DMG.sha256" - if gh release view "$TAG" >/dev/null 2>&1; then - echo "Release $TAG already exists; refusing to replace immutable assets" - exit 1 - fi - if [ "$SIGNING_MODE" = "self-signed" ]; then - RELEASE_NOTE=$'> [!WARNING]\n> This release is signed with the project self-signed certificate and is not Apple-notarized. macOS may require manual approval before opening it.' - else - RELEASE_NOTE=$'> [!NOTE]\n> This release is signed with Apple Developer ID and notarized by Apple.' - fi - gh release create "$TAG" \ - --draft \ - --title "Utter $TAG" \ - --generate-notes \ - --notes "$RELEASE_NOTE" \ - --verify-tag - gh release upload "$TAG" "$DMG" "$CHECKSUM" + - name: Run unit tests + run: swift test - DOWNLOAD_DIR="$(mktemp -d)" - trap 'rm -r "$DOWNLOAD_DIR"' EXIT - gh release download "$TAG" \ - --pattern "$(basename "$DMG")" \ - --pattern "$(basename "$CHECKSUM")" \ - --dir "$DOWNLOAD_DIR" - ( - cd "$DOWNLOAD_DIR" - shasum -c "$(basename "$CHECKSUM")" - ) - cmp "$DMG" "$DOWNLOAD_DIR/$(basename "$DMG")" - gh release edit "$TAG" --draft=false --latest + release: + name: Sign, Verify & Publish + needs: [validate] + uses: ./.github/workflows/release-artifact.yml + with: + # `v0.0.46` -> `0.0.46`; the same script the reusable workflow enforces. + version: ${{ github.ref_name }} + tag: ${{ github.ref_name }} + secrets: inherit diff --git a/docs/sdlc/changes/2026-09-18-nightly-release-pipeline/intent.md b/docs/sdlc/changes/2026-09-18-nightly-release-pipeline/intent.md new file mode 100644 index 00000000..56058a2b --- /dev/null +++ b/docs/sdlc/changes/2026-09-18-nightly-release-pipeline/intent.md @@ -0,0 +1,76 @@ +# Intent: Scheduled nightly releases from `main` + +**Status:** pending approval +**Approved-by:** — +**Approved-date:** — +**Upstream:** User request in IDE-4 ("优化发布流程,晚上8点如果有变更 自动构建 nightly 包,用 wf 打包 release(和现在流程反过来)") + +## Problem + +Releasing today requires a human to create and push a `vMAJOR.MINOR.PATCH` tag, +which then triggers `.github/workflows/release.yml`. Nothing is released until +someone remembers to tag, so `main` can accumulate verified fixes for weeks +(the last release, `v0.0.45`, shipped 14 days after the one before it) even +though every change already passed the PR gate. + +The user wants the direction reversed for routine releases: a scheduled check +should decide whether `main` moved, and if it did, build, sign, verify, and +publish the next patch release without a manual tag. + +## Outcome + +- A scheduled workflow runs daily at 20:00 Asia/Shanghai (12:00 UTC). +- When `main` has no commits newer than the latest stable release tag, the run + skips and states why in the logs and step summary. +- When `main` has new commits, the workflow creates the next patch tag and + publishes a normal (non-prerelease) GitHub Release with `Utter-.dmg` + and its `.sha256`, using the same signing/verification/publish steps and the + same secrets as the manual tag workflow. +- The manual tag-triggered workflow keeps working unchanged as a fallback. + +## Scope + +- Affected: GitHub Actions release automation, `main` tag creation policy, + release notes format. +- In scope: `nightly-release.yml` (schedule + dispatch), a shared reusable + artifact workflow, a change-detection/version-bump script plus shell tests, + SDLC artifacts for this change. +- Non-goals: app/product behavior, signing identity, notarization credentials, + the `production` environment protection rules, the DMG build script, and the + manual tag workflow's trigger. + +## Constraints + +- High-risk lane: signing, release, production publish, and automation that can + write tags to `main`. +- Never tag a commit that is not the current `origin/main` tip; never move, + delete, or force-push a tag; never replace published release assets. +- Never fall back to ad-hoc signing when the configured identity cannot be + imported or the artifact fails verification. +- Releases stay non-prerelease patch releases; the version must be valid per + `scripts/release-version.sh`. +- The scheduled job must not overlap itself. + +## Acceptance criteria + +- `nightly-release.yml` declares `cron: "0 12 * * *"`, `workflow_dispatch`, and + a concurrency group that does not cancel an in-progress publishing run. +- With no commits since the latest stable tag, the plan step reports + `changed=false` with a reason and the release jobs do not run. +- With new commits, the plan step reports the next patch version, and the + signing/publish steps are the same ones the tag workflow uses (shared + reusable workflow, not a copy). +- `scripts/tests/test_nightly_release_plan.sh` covers change detection, patch + bumping, version ordering, prerelease-tag rejection, invalid inputs, and the + workflow wiring; it runs from `scripts/ci-basic-checks.sh`. +- A dry `workflow_dispatch` run on a branch/PR produces the plan output; the + production path is human-gated through the `production` environment. +- SDLC gate passes for this bundle. + +## Open questions + +- Confirm the schedule time and timezone (assumed 20:00 Asia/Shanghai). +- Confirm routine nightly releases should be normal patch releases rather than + `nightly-*` prereleases. +- Confirm whether nightly should also create a git tag on `main` (it does, so + the release is reproducible and the shared pipeline's ancestry check holds). diff --git a/docs/sdlc/changes/2026-09-18-nightly-release-pipeline/plan.md b/docs/sdlc/changes/2026-09-18-nightly-release-pipeline/plan.md new file mode 100644 index 00000000..eb66b0f1 --- /dev/null +++ b/docs/sdlc/changes/2026-09-18-nightly-release-pipeline/plan.md @@ -0,0 +1,42 @@ +# Plan: Scheduled nightly releases from `main` + +**Status:** pending approval +**Approved-by:** — +**Approved-date:** — +**Upstream:** docs/sdlc/changes/2026-09-18-nightly-release-pipeline/spec.md + +## Work items + +- [x] Add `scripts/nightly-release-plan.sh`: stable-tag discovery (numeric + ordering), branch-tip comparison, patch bump, validated output fields. +- [x] Add `scripts/tests/test_nightly_release_plan.sh`: fixture-repo coverage + plus workflow-wiring assertions; wire it into `scripts/ci-basic-checks.sh`. +- [x] Extract the sign/verify/publish job into + `.github/workflows/release-artifact.yml` (`workflow_call`) with + `version`, `tag`, `create_tag`, `require_ancestor` inputs. +- [x] Rewrite `.github/workflows/release.yml` to keep its `validate` job and + call the reusable workflow; keep the `v*` tag trigger unchanged. +- [x] Add `.github/workflows/nightly-release.yml`: cron `0 12 * * *` + + `workflow_dispatch`, non-cancelling concurrency, `plan` -> + `validate` -> reusable release with `create_tag: true`. +- [x] Update `scripts/tests/test_release_version.sh` to assert the shared + guardrails against `release-artifact.yml`. +- [x] Add the `docs/sdlc/changes/2026-09-18-nightly-release-pipeline/` bundle. + +## Verification plan + +- [x] `bash scripts/tests/test_nightly_release_plan.sh` +- [x] `bash scripts/tests/test_release_version.sh` and `test_build_version.sh` +- [x] `bash scripts/sdlc-checks.sh` +- [x] `bash scripts/ci-basic-checks.sh` +- [x] `swift test` (regression: no app-code change) +- [ ] PR CI: Contract & Tests / Release-style App Build / SDLC Gate +- [ ] Post-merge: `workflow_dispatch` the nightly workflow and confirm the + plan output/skip reason; first scheduled run is the production evidence. + +## Human gates + +- User confirms the two open assumptions in the intent: 20:00 Asia/Shanghai + schedule and normal patch releases rather than `nightly-*` prereleases. +- Approving this bundle is the design/rollback review for a release change. +- The `production` environment approval on each real release remains human. diff --git a/docs/sdlc/changes/2026-09-18-nightly-release-pipeline/spec.md b/docs/sdlc/changes/2026-09-18-nightly-release-pipeline/spec.md new file mode 100644 index 00000000..ed3d2332 --- /dev/null +++ b/docs/sdlc/changes/2026-09-18-nightly-release-pipeline/spec.md @@ -0,0 +1,110 @@ +# Spec: Scheduled nightly releases from `main` + +**Status:** pending approval +**Approved-by:** — +**Approved-date:** — +**Upstream:** docs/sdlc/changes/2026-09-18-nightly-release-pipeline/intent.md + +## Context + +`release.yml` currently owns both the release candidate checks and the +signing/publish steps. It is triggered only by pushing a `v*` tag. The +`release` job runs in the protected `production` environment, imports +`APPLE_CERTIFICATE_P12`, verifies the built app's authority against the +configured identity, publishes a draft release, re-downloads the assets, +checks the SHA-256, and only then flips the release to published + latest. + +Reusable workflows can consume `secrets: inherit`, `environment`, and +`permissions`, but the expression context of a reusable workflow's caller is +**not** available in the callee, and a `needs` value from the top-level +workflow cannot be passed as an input. A workflow also cannot create the tag +that triggered it, so the nightly entry point must create the tag itself from +inside the callee. + +## Design + +Three layers: + +1. `scripts/nightly-release-plan.sh [branch=origin/main]` + Read-only planner. Finds the highest stable `vMAJOR.MINOR.PATCH` tag (numeric + ordering, prerelease/build tags ignored), resolves the branch tip, and: + - no stable tag yet -> `changed=true`, `version=0.0.1`; + - tip == tag commit -> `changed=false` + `reason=`; + - otherwise -> `changed=true`, `version=`, `latest_tag=`, + `head_sha=`, `commit_count=`, `commit_summary=` (newest-first subjects). + The emitted version is validated through `release-version.sh`, so the + planner cannot propose a tag the release validator would reject. + +2. `.github/workflows/release-artifact.yml` (reusable, `workflow_call`) + Owns the entire sign/verify/publish pipeline, extracted verbatim from + `release.yml`'s `release` job: credential check, certificate import with the + OpenSSL 3 `-legacy` fallback, Metal toolchain, signed build, authority + + `verify-release-artifact.sh`, Developer ID notarization when applicable, + checksum, draft publish, download + checksum + byte compare, then + `--draft=false --latest`. Inputs: `version`, `tag`, `create_tag`, + `require_ancestor`. Job name stays `Sign, Verify & Publish` so release + evidence and the test assertions keep matching. + +3. Entry points + - `release.yml` keeps its `validate` job (tag SemVer + main ancestry, + repository checks, unit tests) and calls the reusable workflow. + - `nightly-release.yml` adds a `plan` job (ubuntu, checkout with tags), + a `validate` job gated on `changed == 'true'`, and calls the reusable + workflow with `create_tag: true`. + +### Tag creation (nightly) + +Inside the reusable workflow, before building: reject an existing remote tag, +require `HEAD == origin/main` tip, create an annotated tag there, and push it. +Then `require_ancestor` is false (the tag was just created on the tip) so the +existing ancestry check is not duplicated. + +## Safety and failure modes + +- **Writes to `main`.** The only write is `git push` of a tag, and only from + the `production`-gated `release` job. The commit must equal the fetched + `origin/main` tip; an existing tag or a moved tip aborts before anything is + pushed. +- **Concurrent runs.** `concurrency: group: nightly-release` with + `cancel-in-progress: false`, so a scheduled run and a dispatch cannot build + the same version, and an in-flight publish is never cancelled. +- **No-op nights.** When `changed=false` the validation and release jobs are + skipped; `production` is never entered and no tag is created. +- **Secrets.** No new secrets; the reusable workflow uses `secrets: inherit` + from callers and still runs in the `production` environment. +- **Failure containment.** Any signing, verification, or checksum failure + aborts before or during the draft publish; the immutable-asset guard + (`gh release view` check) prevents replacing a published release, and there + is still no ad-hoc signing fallback. +- **Rejected alternatives.** Inlining the pipeline in `nightly-release.yml` + (drift risk), calling `release.yml` via `workflow_dispatch` with an input + (cannot gate on a tag that does not exist yet), and shelling out from a + workflow to `git push` a signed tag (no signature infrastructure). + +## Test strategy + +- Local: `bash scripts/tests/test_nightly_release_plan.sh`, run from + `scripts/ci-basic-checks.sh`. Fixture repos cover: first release, no-op, + patch bump, `9 -> 10` rollover, numeric tag ordering, prerelease rejection, + unknown branch, and missing arguments. The same test asserts the workflow + wiring (schedule, dispatch, concurrency, `create_tag`, shared reusable + workflow) and re-asserts the guardrails that previously lived in + `test_release_version.sh`. +- Local: `bash scripts/sdlc-checks.sh`, `bash scripts/ci-basic-checks.sh`, + `swift test` (unchanged app code, so this is regression evidence). +- PR CI: Contract & Tests, Release-style App Build, SDLC Gate. +- Manual after merge: `workflow_dispatch` the nightly workflow and inspect the + plan output/skip reason; the first real nightly is the production evidence. + Actual publish cannot be exercised on a PR because the `production` + environment is tag-restricted. + +## Rollout and rollback + +1. Merge this change through a PR (SDLC Gate green). +2. Optionally `workflow_dispatch` once to observe the decision without + publishing (it will publish if `main` has unreleased commits — do this only + when a release is acceptable). +3. Observe the first scheduled run at 12:00 UTC. +4. Rollback: disable the scheduled trigger (or revert `nightly-release.yml` + and this bundle). The manual tag workflow is untouched and keeps working. + Already published releases and pushed tags are immutable and stay. diff --git a/docs/sdlc/changes/2026-09-18-nightly-release-pipeline/verification.md b/docs/sdlc/changes/2026-09-18-nightly-release-pipeline/verification.md new file mode 100644 index 00000000..cd9e9181 --- /dev/null +++ b/docs/sdlc/changes/2026-09-18-nightly-release-pipeline/verification.md @@ -0,0 +1,61 @@ +# Verification: Scheduled nightly releases from `main` + +**Status:** pending approval +**Approved-by:** — +**Approved-date:** — +**Upstream:** docs/sdlc/changes/2026-09-18-nightly-release-pipeline/plan.md + +## Evidence + +| Check | Result | Evidence | +|---|---|---| +| `bash scripts/tests/test_nightly_release_plan.sh` | Pass | "Nightly release plan tests passed." (first release 0.0.1 / no-op / patch bump 0.0.46 / rollover 0.1.9->0.1.10 / numeric ordering v0.0.10 > v0.0.9 / prerelease rejected / unknown branch + missing args fail / workflow wiring / shared-pipeline guardrails) | +| `bash scripts/tests/test_release_version.sh` | Pass | Guardrails re-asserted against `release-artifact.yml` | +| `bash scripts/tests/test_build_version.sh` | Pass | Unchanged | +| `bash scripts/sdlc-checks.sh` | Pass | "SDLC checks passed." | +| Workflow YAML parses | Pass | `release.yml` jobs `[validate, release]`, `release-artifact.yml` `[release]`, `nightly-release.yml` `[plan, validate, release]` | +| `bash scripts/ci-basic-checks.sh` | Pass | "Basic CI checks passed." | +| `swift test` (focused regression) | Pass | 396 passed / 8 skipped / 0 failures (no app-code change) | +| PR CI: Contract & Tests / Release-style App Build / SDLC Gate | Not run | Pending PR | + +## Acceptance criteria + +- Schedule + dispatch + non-cancelling concurrency — pass (workflow source, + asserted by the shell test). +- No-op skip with a stated reason, and release jobs gated on `changed` — pass + (shell test plus `if: needs.plan.outputs.changed == 'true'` assertions). +- Patch bump and validated version — pass (shell test; every emitted version is + re-validated by `release-version.sh`). +- Signing/publish shared with the tag workflow, not copied — pass + (`release.yml` and `nightly-release.yml` both call + `release-artifact.yml`; guardrail greps moved there). +- Guardrails preserved: self-signed/Developer-ID modes, checksum verification, + immutable-asset refusal, no ad-hoc fallback — pass (assertions in both shell + tests). +- SDLC gate passes — pass locally; PR gate pending. +- Production publish evidence — deferred to the first gated run (not + reproducible on a PR because `production` is tag-restricted). + +## Residual risk + +- The reusable workflow changes the release path for *both* entry points, so a + defect would affect manual releases too. Mitigated by keeping the step bodies + byte-identical in intent and by the guardrail assertions; owner: release + maintainer. +- `create_tag` runs inside the `production`-gated job, so a nightly release + still needs environment approval when that protection is active; an + unapproved run blocks (no partial publish). Owner: repository admin. +- Scheduled workflows are disabled automatically after 60 days of repository + inactivity and only run on the default branch; owner: release maintainer. +- Nightly runs will publish whatever is on `main` at 20:00 Asia/Shanghai. If a + stricter staging window is wanted, a `nightly-*` prerelease variant is a small + change (see intent open questions). Owner: user. +- `workflow_dispatch` of the nightly workflow publishes when there are + unreleased commits; the runbook/PR description must say so. Owner: release + maintainer. + +## Decision + +Ready for review. Human approval is recorded separately in the artifact +headers; the production release itself still requires the `production` +environment approval. diff --git a/scripts/ci-basic-checks.sh b/scripts/ci-basic-checks.sh index 97c262ce..3797c5de 100755 --- a/scripts/ci-basic-checks.sh +++ b/scripts/ci-basic-checks.sh @@ -24,6 +24,7 @@ step "Checking SDLC artifacts and harness regression tests" bash scripts/sdlc-checks.sh bash scripts/tests/test_build_version.sh bash scripts/tests/test_release_version.sh +bash scripts/tests/test_nightly_release_plan.sh step "Linting property lists and localized strings" plutil -lint Resources/Info.plist diff --git a/scripts/nightly-release-plan.sh b/scripts/nightly-release-plan.sh new file mode 100755 index 00000000..dc0c0fd2 --- /dev/null +++ b/scripts/nightly-release-plan.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# +# Nightly release candidates: decide whether `main` has moved since the latest +# release tag and, if so, print the next patch version. +# +# Read-only. It never creates tags or commits; the nightly workflow performs the +# write action after a human-reviewed change is merged. +# +# Usage: nightly-release-plan.sh [branch] +# repository path to a git checkout (defaults are resolved by the caller) +# branch branch whose tip is compared (default: origin/main) +# +# Output (stdout), in this order when a release is warranted: +# version= next patch version, validated by release-version.sh +# latest_tag= latest stable release tag, if any +# head_sha= commit the tag will be created on +# commit_count= commits on the branch since that tag +# commit_summary= newest commit subjects, newest first +# +# Prints `changed=false` plus a `reason=` line and exits 0 when nothing should be +# released (no stable tag yet is treated as "release", with `latest_tag=` empty). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RELEASE_VERSION="$SCRIPT_DIR/release-version.sh" + +REPOSITORY="${1:-}" +BRANCH="${2:-origin/main}" + +if [ -z "$REPOSITORY" ]; then + echo "usage: $0 [branch]" >&2 + exit 2 +fi +if [ ! -d "$REPOSITORY/.git" ]; then + echo "error: $REPOSITORY is not a git checkout" >&2 + exit 1 +fi + +# Stable release tags only, highest by version order (`v0.0.10` > `v0.0.9`). +latest_tag="" +while IFS= read -r tag; do + [ -n "$tag" ] || continue + if "$RELEASE_VERSION" "$tag" >/dev/null 2>&1; then + latest_tag="$tag" + fi +done < <(git -C "$REPOSITORY" tag -l 'v*' | sort -V) + +head_sha="$(git -C "$REPOSITORY" rev-parse --verify "${BRANCH}^{commit}" 2>/dev/null || true)" +if [ -z "$head_sha" ]; then + echo "error: cannot resolve branch '$BRANCH' in $REPOSITORY" >&2 + exit 1 +fi + +if [ -z "$latest_tag" ]; then + commit_count="$(git -C "$REPOSITORY" rev-list --count "$head_sha")" + next_version="0.0.1" + range_summary="$(git -C "$REPOSITORY" log --max-count=10 --pretty=format:'%h %s' "$head_sha")" +else + if ! git -C "$REPOSITORY" merge-base --is-ancestor "$latest_tag" "$head_sha" 2>/dev/null; then + echo "error: latest tag $latest_tag is not an ancestor of $BRANCH" >&2 + exit 1 + fi + commit_count="$(git -C "$REPOSITORY" rev-list --count "$latest_tag..$head_sha")" + if [ "$commit_count" -eq 0 ]; then + echo "changed=false" + echo "reason=no commits on $BRANCH since $latest_tag" + echo "latest_tag=$latest_tag" + echo "head_sha=$head_sha" + echo "commit_count=0" + exit 0 + fi + # Next patch version; refuse to guess past patch 9_999_999. + numeric="$("$RELEASE_VERSION" "$latest_tag")" + major="${numeric%%.*}" + rest="${numeric#*.}" + minor="${rest%%.*}" + patch="${rest##*.}" + next_version="$major.$minor.$((patch + 1))" + range_summary="$(git -C "$REPOSITORY" log --max-count=10 --pretty=format:'%h %s' "$latest_tag..$head_sha")" +fi + +# The generated tag must itself pass the release validator. +"$RELEASE_VERSION" "v$next_version" >/dev/null + +# Single-line summary for `$GITHUB_OUTPUT`; keep any `%` intact. +commit_summary="$(printf '%s' "$range_summary" | tr '\n' ';' | tr -d '\r')" + +echo "changed=true" +echo "version=$next_version" +echo "latest_tag=$latest_tag" +echo "head_sha=$head_sha" +echo "commit_count=$commit_count" +echo "commit_summary=$commit_summary" diff --git a/scripts/tests/test_nightly_release_plan.sh b/scripts/tests/test_nightly_release_plan.sh new file mode 100755 index 00000000..ec576a5a --- /dev/null +++ b/scripts/tests/test_nightly_release_plan.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# +# Tests for scripts/nightly-release-plan.sh: change detection, patch bumping, +# and the workflow wiring that consumes it. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PLANNER="$SCRIPT_DIR/nightly-release-plan.sh" +REPOSITORY="$(cd "$SCRIPT_DIR/.." && pwd)" +WORKFLOW="$REPOSITORY/.github/workflows/nightly-release.yml" +ARTIFACT_WORKFLOW="$REPOSITORY/.github/workflows/release-artifact.yml" +RELEASE_WORKFLOW="$REPOSITORY/.github/workflows/release.yml" + +FIXTURE="$(mktemp -d)" +trap 'rm -r "$FIXTURE"' EXIT + +fail() { + echo "error: $*" >&2 + exit 1 +} + +field() { + # field + printf '%s\n' "$1" | sed -n "s/^$2=//p" | head -1 +} + +git -C "$FIXTURE" init -q -b main +git -C "$FIXTURE" -c user.name=Test -c user.email=test@example.com \ + commit --allow-empty -qm base + +# No stable tag yet -> first release, version 0.0.1. +out="$("$PLANNER" "$FIXTURE" main)" +[ "$(field "$out" changed)" = "true" ] || fail "expected changed=true without a tag" +[ "$(field "$out" version)" = "0.0.1" ] || fail "first release must be 0.0.1" +[ "$(field "$out" latest_tag)" = "" ] || fail "latest_tag must be empty without a tag" + +# Stable tag on the tip -> nothing to release. +git -C "$FIXTURE" tag v0.0.45 +out="$("$PLANNER" "$FIXTURE" main)" +[ "$(field "$out" changed)" = "false" ] || fail "expected changed=false at the tagged tip" +[ "$(field "$out" commit_count)" = "0" ] || fail "expected commit_count=0" +printf '%s\n' "$out" | grep -q '^reason=' || fail "skipped runs must state a reason" + +# One new commit -> patch bump. +git -C "$FIXTURE" -c user.name=Test -c user.email=test@example.com \ + commit --allow-empty -qm "feat: nightly" +out="$("$PLANNER" "$FIXTURE" main)" +[ "$(field "$out" changed)" = "true" ] || fail "expected changed=true after a new commit" +[ "$(field "$out" version)" = "0.0.46" ] || fail "expected patch bump to 0.0.46" +[ "$(field "$out" latest_tag)" = "v0.0.45" ] || fail "expected latest_tag=v0.0.45" +[ "$(field "$out" commit_count)" = "1" ] || fail "expected commit_count=1" +[ "$(field "$out" head_sha)" = "$(git -C "$FIXTURE" rev-parse HEAD)" ] \ + || fail "head_sha must be the branch tip" +printf '%s\n' "$out" | grep -q '^commit_summary=.*feat: nightly' \ + || fail "commit_summary must list the new commit" + +# Patch bump keeps major/minor and rolls over 9 -> 10. +git -C "$FIXTURE" tag v0.0.46 +git -C "$FIXTURE" tag v0.1.9 +git -C "$FIXTURE" -c user.name=Test -c user.email=test@example.com \ + commit --allow-empty -qm "fix: rollover" +out="$("$PLANNER" "$FIXTURE" main)" +[ "$(field "$out" version)" = "0.1.10" ] || fail "expected 0.1.10, got $(field "$out" version)" +[ "$(field "$out" latest_tag)" = "v0.1.9" ] || fail "expected v0.1.9 to win version ordering" + +# Version ordering is numeric, not lexicographic (v0.0.9 < v0.0.10). +FIXTURE2="$(mktemp -d)" +git -C "$FIXTURE2" init -q -b main +git -C "$FIXTURE2" -c user.name=Test -c user.email=test@example.com \ + commit --allow-empty -qm base +git -C "$FIXTURE2" tag v0.0.9 +git -C "$FIXTURE2" tag v0.0.10 +out="$("$PLANNER" "$FIXTURE2" main)" +[ "$(field "$out" latest_tag)" = "v0.0.10" ] || fail "expected v0.0.10 as latest" +rm -r "$FIXTURE2" + +# Prerelease/build tags are never treated as release tags. +FIXTURE3="$(mktemp -d)" +git -C "$FIXTURE3" init -q -b main +git -C "$FIXTURE3" -c user.name=Test -c user.email=test@example.com \ + commit --allow-empty -qm base +git -C "$FIXTURE3" tag v1.2.3-beta +out="$("$PLANNER" "$FIXTURE3" main)" +[ "$(field "$out" version)" = "0.0.1" ] || fail "prerelease tags must be ignored" +rm -r "$FIXTURE3" + +# Unknown branch and non-repository paths fail closed. +if "$PLANNER" "$FIXTURE" does-not-exist >/dev/null 2>&1; then + fail "unknown branch must fail" +fi +if "$PLANNER" >/dev/null 2>&1; then + fail "missing repository argument must fail" +fi + +# The nightly workflow must be scheduled and dispatchable, must serialize runs, +# and must tag a main commit through the shared artifact pipeline. +grep -Fq 'cron: "0 12 * * *"' "$WORKFLOW" || fail "nightly schedule must be 12:00 UTC" +grep -Fq 'workflow_dispatch:' "$WORKFLOW" || fail "nightly workflow needs workflow_dispatch" +grep -Fq 'group: nightly-release' "$WORKFLOW" || fail "nightly workflow needs a concurrency group" +grep -Fq 'cancel-in-progress: false' "$WORKFLOW" || fail "nightly runs must not cancel a publishing run" +grep -Fq "if: \${{ needs.plan.outputs.changed == 'true' }}" "$WORKFLOW" \ + || fail "release jobs must be gated on detected changes" +grep -Fq 'create_tag: true' "$WORKFLOW" || fail "nightly must create the tag through the reusable workflow" +grep -Fq './scripts/nightly-release-plan.sh . origin/main' "$WORKFLOW" \ + || fail "nightly must plan through the script, not inline logic" + +# Both release entry points must share the artifact pipeline. +for workflow in "$RELEASE_WORKFLOW" "$WORKFLOW"; do + grep -Fq 'uses: ./.github/workflows/release-artifact.yml' "$workflow" \ + || fail "$workflow must call the reusable artifact workflow" +done + +# Guardrails copied from the tag workflow must survive in the shared pipeline. +grep -Fq 'SIGNING_MODE=self-signed' "$ARTIFACT_WORKFLOW" +[ "$(grep -Fc 'VERIFY_ARGS+=(--require-self-signed)' "$ARTIFACT_WORKFLOW")" -eq 2 ] +grep -Fq 'VERIFY_ARGS+=(--require-developer-id --require-notarization)' "$ARTIFACT_WORKFLOW" +[ "$(grep -Fc -- '--expected-cert-sha256 "$SIGN_CERT_SHA256"' "$ARTIFACT_WORKFLOW")" -eq 2 ] +grep -Fq "if: env.SIGNING_MODE == 'developer-id'" "$ARTIFACT_WORKFLOW" +grep -Fq 'This release is signed with the project self-signed certificate' "$ARTIFACT_WORKFLOW" +grep -Fq 'refusing to replace immutable assets' "$ARTIFACT_WORKFLOW" +if grep -Eq -- '--clobber|--sign=-|will use ad-hoc' "$ARTIFACT_WORKFLOW"; then + fail "release pipeline can replace assets or fall back to ad-hoc signing" +fi + +echo "Nightly release plan tests passed." diff --git a/scripts/tests/test_release_version.sh b/scripts/tests/test_release_version.sh index 60c4067f..e3fdd4ef 100755 --- a/scripts/tests/test_release_version.sh +++ b/scripts/tests/test_release_version.sh @@ -5,7 +5,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" VALIDATOR="$SCRIPT_DIR/release-version.sh" REPOSITORY="$(cd "$SCRIPT_DIR/.." && pwd)" -WORKFLOW="$REPOSITORY/.github/workflows/release.yml" +WORKFLOW="$REPOSITORY/.github/workflows/release-artifact.yml" for tag in v0.0.0 v1.2.3 v10.20.300; do expected="${tag#v}"