diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d78fad92..ab7a247c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,13 +35,7 @@ jobs: node-version: 22.22.3 cache: npm - - name: Install locked dependencies - run: npm ci - - - name: Install Playwright Chromium for Generative UI containment - run: npx playwright install chromium - - - name: Assign the main-push version + - name: Resolve the declared release version id: version shell: bash run: | @@ -52,46 +46,69 @@ jobs: if [[ -n "$base_tag_match" ]]; then base_tag_exists=true fi - release_version="$(node scripts/prepare-ci-release.mjs "$GITHUB_RUN_NUMBER" "$base_tag_exists")" - npm version "$release_version" --no-git-tag-version --allow-same-version + selection="$(node scripts/prepare-ci-release.mjs "$base_tag_exists")" + release_version="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).version)' "$selection")" + release_tag="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).tag)' "$selection")" + should_publish="$(node -e 'process.stdout.write(String(JSON.parse(process.argv[1]).publish))' "$selection")" echo "version=$release_version" >> "$GITHUB_OUTPUT" - echo "tag=v$release_version" >> "$GITHUB_OUTPUT" + echo "tag=$release_tag" >> "$GITHUB_OUTPUT" + echo "publish=$should_publish" >> "$GITHUB_OUTPUT" + if [[ "$should_publish" != true ]]; then + echo "$release_tag already exists; skipping release without changing its version." + fi + + - name: Install locked dependencies + if: ${{ steps.version.outputs.publish == 'true' }} + run: npm ci + + - name: Install Playwright Chromium for Generative UI containment + if: ${{ steps.version.outputs.publish == 'true' }} + run: npx playwright install chromium - name: Verify Homebrew and website release consumers + if: ${{ steps.version.outputs.publish == 'true' }} run: npm run release:check-consumers - name: Verify TypeScript + if: ${{ steps.version.outputs.publish == 'true' }} run: npm run type-check - name: Verify lint + if: ${{ steps.version.outputs.publish == 'true' }} run: npm run lint - name: Verify diagnostics privacy and storage contracts + if: ${{ steps.version.outputs.publish == 'true' }} run: npm run test:diagnostics - name: Verify branding, updater, and release policy + if: ${{ steps.version.outputs.publish == 'true' }} run: npm run test:branding - name: Run JavaScript, TypeScript, and Computer Use tests + if: ${{ steps.version.outputs.publish == 'true' }} env: AIDEN_SKIP_VIRTUALIZED_LAUNCH_CONSTRAINT_TESTS: "1" run: npm test - name: Run Apple Foundation Models tests + if: ${{ steps.version.outputs.publish == 'true' }} run: npm run test:native - name: Run deterministic Electron E2E gate + if: ${{ steps.version.outputs.publish == 'true' }} run: npm run test:e2e - name: Verify production-profile diagnostics support workflow + if: ${{ steps.version.outputs.publish == 'true' }} run: npm run test:e2e:diagnostics:production - name: Build sanitized E2E failure receipt - if: ${{ failure() }} + if: ${{ failure() && steps.version.outputs.publish == 'true' }} run: npm run diagnostics:failure-receipt -- test-results/e2e-safe-receipt.json electron-e2e test-failed - name: Upload sanitized E2E failure receipt - if: ${{ failure() }} + if: ${{ failure() && steps.version.outputs.publish == 'true' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: playwright-e2e-release-${{ github.run_id }}-${{ github.run_attempt }} @@ -100,6 +117,7 @@ jobs: retention-days: 7 - name: Prepare App Store Connect key + if: ${{ steps.version.outputs.publish == 'true' }} shell: bash env: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} @@ -114,6 +132,7 @@ jobs: echo "APPLE_API_KEY=$key_path" >> "$GITHUB_ENV" - name: Build, sign, notarize, and verify distribution + if: ${{ steps.version.outputs.publish == 'true' }} env: AIDEN_ENABLE_AUTO_UPDATES: "1" APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} @@ -123,9 +142,11 @@ jobs: run: npm run dist - name: Verify diagnostics in the signed packaged app + if: ${{ steps.version.outputs.publish == 'true' }} run: npm run test:e2e:diagnostics:packaged - name: Publish verified release assets + if: ${{ steps.version.outputs.publish == 'true' }} shell: bash env: GH_TOKEN: ${{ github.token }} diff --git a/docs/releasing.md b/docs/releasing.md index 8ad9cffc..66194235 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -9,10 +9,11 @@ visitors and installed apps can download GitHub Release assets without a GitHub - `.github/workflows/ci.yml` verifies pull requests and pushes on GitHub's `macos-26` image. - `.github/workflows/release.yml` is considered enabled only when the source repository variable `RELEASES_ENABLED` is exactly `true`. -- Each enabled push to `main` derives a monotonically increasing version from the workflow run - number. The beta line starts at `0.27.0`; the base `package.json` version supplies that release - line, so `0.27.0` plus run `41` produces `0.27.41` without committing a version-bump loop to - `main`. +- A release uses the complete SemVer declared in `package.json` exactly. For example, `0.35.0` + publishes only as `v0.35.0`; workflow run numbers never become application versions. +- If the exact declared tag already exists, an enabled push to `main` completes the release job as + a green no-op. Publishing another build requires an explicit reviewed version change in both + `package.json` and `package-lock.json`. - The release job runs the full TypeScript, lint, JavaScript/TypeScript, Rust, Swift, and build gates before preparing signing material. - GitHub-hosted macOS VMs do not enforce Aiden's live kernel launch constraint. CI still verifies @@ -44,8 +45,9 @@ visitors and installed apps can download GitHub Release assets without a GitHub retry, and ready states. A two-minute no-progress stall is cancelled and retried with bounded backoff. Installation starts only after the package and macOS updater handoff are ready, and never interrupts an open workspace or bypasses the existing quit barriers. -- A failed or rerun job refuses to overwrite an existing tag. Recovery is a new `main` commit, - which receives a higher version. +- A failed or rerun job refuses to overwrite an existing tag. If no tag or draft was created, rerun + the same declared version. If a partial draft exists, inspect and remove only that exact failed + draft before retrying. Never manufacture a version from a workflow run number. Local `npm run dist` builds do not embed a feed or perform automatic update checks. The release workflow opts in with `AIDEN_ENABLE_AUTO_UPDATES=1`. @@ -113,7 +115,6 @@ Never place an Apple private key, certificate password, or notarization credenti ## Version-line changes -For a planned minor or major release, change only the major/minor line in `package.json` and -`package-lock.json` (for example, `0.27.0` to `0.28.0`). The next workflow run becomes -`0.28.`, which remains greater than every `0.27.x` build. Do not lower the -major/minor line or manually reuse a published version. +For every planned release, change the complete version in `package.json` and `package-lock.json` +(for example, `0.35.0` to `0.35.1`, or `0.35.0` to `0.36.0`). The next eligible workflow run uses +that exact version. Do not lower the version or reuse a published tag. diff --git a/scripts/prepare-ci-release.mjs b/scripts/prepare-ci-release.mjs index 4a7ca282..fe9fb2d4 100644 --- a/scripts/prepare-ci-release.mjs +++ b/scripts/prepare-ci-release.mjs @@ -7,34 +7,30 @@ import { fileURLToPath } from "node:url"; const modulePath = fileURLToPath(import.meta.url); const repositoryRoot = path.resolve(path.dirname(modulePath), ".."); -export function automaticReleaseVersion(baseVersion, runNumber) { - const match = /^(\d+)\.(\d+)\.\d+(?:-[0-9A-Za-z.-]+)?$/u.exec(baseVersion); - if (!match) throw new Error(`Invalid base release version: ${baseVersion}`); - if (!/^[1-9]\d*$/u.test(String(runNumber))) { - throw new Error(`GitHub run number must be a positive integer: ${runNumber}`); +export function mainPushReleaseSelection(baseVersion, baseTagExists) { + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/u.test(baseVersion)) { + throw new Error(`Invalid declared release version: ${baseVersion}`); } - return `${Number(match[1])}.${Number(match[2])}.${Number(runNumber)}`; -} - -export function mainPushReleaseVersion(baseVersion, runNumber, baseTagExists) { - const incrementedVersion = automaticReleaseVersion(baseVersion, runNumber); if (typeof baseTagExists !== "boolean") { - throw new Error("Base tag existence must be a boolean"); + throw new Error("Declared tag existence must be a boolean"); } - return baseTagExists ? incrementedVersion : baseVersion; + return Object.freeze({ + version: baseVersion, + tag: `v${baseVersion}`, + publish: !baseTagExists, + }); } if (process.argv[1] && path.resolve(process.argv[1]) === modulePath) { const packageJson = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8")); - const baseTagExistsArgument = process.argv[3]; + const baseTagExistsArgument = process.argv[2]; if (baseTagExistsArgument !== "true" && baseTagExistsArgument !== "false") { - throw new Error("Base tag existence must be provided as true or false"); + throw new Error("Declared tag existence must be provided as true or false"); } console.log( - mainPushReleaseVersion( + JSON.stringify(mainPushReleaseSelection( packageJson.version, - process.argv[2], baseTagExistsArgument === "true", - ), + )), ); } diff --git a/scripts/prepare-ci-release.test.mjs b/scripts/prepare-ci-release.test.mjs index c081e25f..8a2ba503 100644 --- a/scripts/prepare-ci-release.test.mjs +++ b/scripts/prepare-ci-release.test.mjs @@ -1,31 +1,31 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - automaticReleaseVersion, - mainPushReleaseVersion, -} from "./prepare-ci-release.mjs"; +import { mainPushReleaseSelection } from "./prepare-ci-release.mjs"; -test("main pushes receive a monotonic patch version inside the declared release line", () => { - assert.equal(automaticReleaseVersion("0.27.0", "41"), "0.27.41"); - assert.equal(automaticReleaseVersion("1.0.0", "41"), "1.0.41"); - assert.equal(automaticReleaseVersion("2.3.0", 42), "2.3.42"); +test("an unpublished declared version is selected exactly", () => { + assert.deepEqual(mainPushReleaseSelection("0.35.0", false), { + version: "0.35.0", + tag: "v0.35.0", + publish: true, + }); }); -test("automatic release versions reject ambiguous inputs", () => { - assert.throws(() => automaticReleaseVersion("1.0", 1), /Invalid base release version/u); - assert.throws(() => automaticReleaseVersion("1.0.0", 0), /positive integer/u); - assert.throws(() => automaticReleaseVersion("1.0.0", "01"), /positive integer/u); +test("an existing declared tag makes release publication a green no-op", () => { + assert.deepEqual(mainPushReleaseSelection("0.35.0", true), { + version: "0.35.0", + tag: "v0.35.0", + publish: false, + }); }); -test("a declared version publishes exactly once before automatic build increments", () => { - assert.equal(mainPushReleaseVersion("0.31.0", 55, false), "0.31.0"); - assert.equal(mainPushReleaseVersion("0.31.0", 56, true), "0.31.56"); -}); - -test("main-push release selection requires an explicit tag state", () => { +test("declared release selection rejects ambiguous inputs", () => { + assert.throws( + () => mainPushReleaseSelection("1.0", false), + /Invalid declared release version/u, + ); assert.throws( - () => mainPushReleaseVersion("0.31.0", 55, undefined), - /Base tag existence must be a boolean/u, + () => mainPushReleaseSelection("1.0.0", undefined), + /Declared tag existence must be a boolean/u, ); }); diff --git a/scripts/run-macos-distribution.test.mjs b/scripts/run-macos-distribution.test.mjs index 88e77df8..63363ae2 100644 --- a/scripts/run-macos-distribution.test.mjs +++ b/scripts/run-macos-distribution.test.mjs @@ -201,9 +201,29 @@ test("release publication checks deployed consumers before building", async () = ); const consumerCheck = workflow.indexOf("npm run release:check-consumers"); const distributionBuild = workflow.indexOf("npm run dist"); + const versionResolution = workflow.indexOf("Resolve the declared release version"); + const dependencyInstall = workflow.indexOf("Install locked dependencies"); assert.match(workflow, /git ls-remote --tags origin/u); - assert.match(workflow, /--allow-same-version/u); + assert.doesNotMatch(workflow, /GITHUB_RUN_NUMBER|--allow-same-version/u); + assert.match(workflow, /node scripts\/prepare-ci-release\.mjs "\$base_tag_exists"/u); + assert.match(workflow, /steps\.version\.outputs\.publish == 'true'/u); + assert.ok(versionResolution >= 0 && versionResolution < dependencyInstall); + for (const stepName of [ + "Install locked dependencies", + "Build, sign, notarize, and verify distribution", + "Verify diagnostics in the signed packaged app", + "Publish verified release assets", + ]) { + assert.match( + workflow, + new RegExp( + `- name: ${stepName.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}\\n` + + " {8}if: \\$\\{\\{ steps\\.version\\.outputs\\.publish == 'true' \\}\\}", + "u", + ), + ); + } assert.ok(consumerCheck >= 0, "the release workflow must check Homebrew and the website"); assert.ok( distributionBuild > consumerCheck,