From efec93e335645a2576f43bc501b7d202054969ca Mon Sep 17 00:00:00 2001 From: idevlab Date: Fri, 18 Sep 2026 13:43:03 +0800 Subject: [PATCH] ci: add scheduled nightly release pipeline Nightly releases are now driven by change detection instead of a manual tag: a scheduled workflow inspects main against the latest stable release tag and, when there are new commits, creates the next patch tag and publishes through the same signed pipeline as the tag workflow. release.yml keeps its tag trigger as a manual fallback. Extract the sign/verify/publish job into the reusable release-artifact.yml so both entry points cannot drift, add scripts/nightly-release-plan.sh for change detection and patch bumping with shell tests wired into ci-basic-checks.sh, and record the change in docs/sdlc/changes/2026-09-18-nightly-release-pipeline/. Co-authored-by: multica-agent --- .github/workflows/nightly-release.yml | 116 ++++++++ .github/workflows/release-artifact.yml | 278 ++++++++++++++++++ .github/workflows/release.yml | 208 +------------ .../intent.md | 76 +++++ .../plan.md | 42 +++ .../spec.md | 110 +++++++ .../verification.md | 61 ++++ scripts/ci-basic-checks.sh | 1 + scripts/nightly-release-plan.sh | 94 ++++++ scripts/tests/test_nightly_release_plan.sh | 126 ++++++++ scripts/tests/test_release_version.sh | 2 +- 11 files changed, 916 insertions(+), 198 deletions(-) create mode 100644 .github/workflows/nightly-release.yml create mode 100644 .github/workflows/release-artifact.yml create mode 100644 docs/sdlc/changes/2026-09-18-nightly-release-pipeline/intent.md create mode 100644 docs/sdlc/changes/2026-09-18-nightly-release-pipeline/plan.md create mode 100644 docs/sdlc/changes/2026-09-18-nightly-release-pipeline/spec.md create mode 100644 docs/sdlc/changes/2026-09-18-nightly-release-pipeline/verification.md create mode 100755 scripts/nightly-release-plan.sh create mode 100755 scripts/tests/test_nightly_release_plan.sh 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}"