diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 6e83bbe9..ab240b23 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -10,46 +10,41 @@ mechanics and failure modes. ## Release model -- **Changesets, not tags.** Contributors land PRs with `.changeset/*.md` entries (authored via the - tracked `gen-changesets` skill, `.agents/skills/gen-changesets/SKILL.md`). Versions and CHANGELOGs - are machine-generated from those entries. Nobody edits a version number by hand. -- **Two-phase CI flow** on every push to `main` (`.github/workflows/release.yml`): - 1. *Pending changesets exist* → changesets action runs `pnpm run version:release` - (= `changeset version`) and opens/updates the **`ci: release packages`** PR. - 2. *That PR merges* → next run finds no pending changesets but bumped versions → publishes via - `node scripts/release/changeset-publish-idempotent.mjs`, creates the GitHub Release at tag - `@pythoughts/pythinker-code@`, and fans out to downstream jobs. -- **Publishing is CI-only** via npm Trusted Publishing (OIDC, `id-token: write`). The workflow - deliberately sets **no `NPM_TOKEN`** — changesets prefers a token over OIDC when one is set, so - adding it would silently downgrade publishing to a long-lived secret. Never "fix" a publish - failure by adding NPM_TOKEN, and never run `changeset publish` locally. -- The root `publish` script in `package.json` chains the full local gate - (`typecheck → lint → sherif → test → build → lint:pkg → changeset publish`) — it exists for gate - parity, not for actually publishing from a laptop. +- **Changesets, not manual version edits.** Contributor PRs add `.changeset/*.md`. The changesets + action creates or updates the `ci: release packages` PR. Merging that PR publishes the public npm + package and creates `@pymodel/pythinker-code@`. +- **npm publishing is CI-only.** Trusted Publishing uses OIDC. Do not add `NPM_TOKEN`; a token takes + precedence over OIDC. Do not run `changeset publish` locally. +- **Private lanes use the push boundary.** `publishedPackages` only lists packages published to npm. + Desktop and VS Code are private workspaces, so `detect-lane-bumps.mjs` compares their versions at + `github.event.before` and `github.sha`. +- **Desktop is tag-driven.** The required `cut-desktop-tag` job creates `desktop-v`, which + starts `desktop-release.yml`. +- **VS Code is isolated.** `vscode-release.yml` supports `workflow_call` and version-checked manual + dispatch. Existing registry versions are skipped by the publisher scripts, so recovery is safe. ## What publishes -`.changeset/config.json` `ignore` list excludes almost every internal package -(`agent-core`, `pyaos`, `kosong`, `server`, dashboards, web, …). Effective publishable set = -non-private, non-ignored workspace packages — in practice **`@pythoughts/pythinker-code`** and the -SDK-adjacent packages not on the ignore list. When adding a workspace package, decide its ignore/ -publish status explicitly, and remember `flake.nix` workspace lists must be updated by hand -(root `AGENTS.md`). +`@pymodel/pythinker-code` is the public npm package. Desktop and VS Code package files are private; +their versions are release signals but changesets does not publish them to npm. When adding a +workspace, set its `private` and changesets policy explicitly and update `flake.nix`. ## release.yml job map | Job | Trigger | Notes | |---|---|---| -| `Release` | every main push | install → build catalog → `pnpm build` → changesets action | -| `Redeploy code.pythinker.com` | `packages_published == 'true'` | runs `scripts/release/verify-release-consistency.mjs`, then POSTs `DOKPLOY_CDN_DEPLOY_WEBHOOK` (skips with a warning if the secret is unset) | -| `Update Homebrew tap` | published | `scripts/release/update-brew-formula.mjs` with `TAP_GITHUB_TOKEN` (skips if unset) | -| `Deploy docs` | published | reusable `docs-deploy.yml` | -| `Native release artifact` | `pythinker_native_release == 'true'` | reusable `_native-build.yml`, macOS signing/notarization secrets | -| `Publish native release assets` | native release | `produce-manifest.mjs` then `gh release upload … --clobber` | +| `Release` | every main push after CI + Nix | Detect lane versions, build, run changesets | +| `Cut desktop release tag` | desktop version changed | Required and idempotent; App token makes the tag trigger the desktop workflow | +| `Publish VS Code extension` | extension version changed | Reusable workflow; six VSIX targets, both registries, provenance | +| `Native release artifact` | CLI was published | Six signed/tested zips, checksums, provenance | +| `Publish native release assets` | native builds passed | All-or-nothing immutable upload with `manifest.json` | +| `Redeploy CDN` + verify | native assets published | Webhook may retry; verification is the hard gate | +| `Update Homebrew tap` | CLI was published | App token scoped to `homebrew-tap` contents | +| `Release lane summary` | always | One table with provenance state; fails when an expected enabled lane failed or skipped | -`pythinker_native_release` and the release tag come from -`apps/pythinker-code/scripts/native/resolve-release.mjs`, driven by the changesets action's -`publishedPackages` output; the tag format is `@pythoughts/pythinker-code@`. +Set `RELEASE_LANE_DESKTOP`, `RELEASE_LANE_VSCODE`, `RELEASE_LANE_CDN`, or +`RELEASE_LANE_BREW` to exactly `disabled` for a conscious temporary opt-out. Missing credentials are +otherwise errors. ## Failure modes and known lessons @@ -61,28 +56,39 @@ publish status explicitly, and remember `flake.nix` workspace lists must be upda genuinely half-published release — read the log; do not blind-rerun. - **Version PR looks wrong.** Never patch the `changeset-release/main` branch by hand. Fix or add changesets on `main`; the next workflow run regenerates the PR. -- **Native builder fails after npm publish succeeded.** npm state is final; native jobs are - re-runnable against the same workflow run (`gh run rerun --failed`). `--clobber` on asset - upload makes re-runs safe. +- **Native builder fails after npm publish succeeded.** npm state is final. Re-run failed jobs from + the same run before any assets upload. A complete asset set is an idempotent no-op. A partial set + must not be filled from a rebuild; keep it or publish a new patch version. - **CDN not updated after publish.** `verify-release-consistency.mjs` gates the webhook: local `apps/pythinker-code/package.json` version must equal the npm `latest` dist-tag (plus sane `beta`/`dev` tags). A mismatch means the checkout in the job predates the release commit or npm - propagation lag — check `npm view @pythoughts/pythinker-code dist-tags` before touching anything. + propagation lag — check `npm view @pymodel/pythinker-code dist-tags` before touching anything. Dokploy deploy specifics: see memory `cdn-dokploy-deploy-pipeline`. - **`pnpm install` fails in CI or locally.** `engine-strict=true` + Node `>=24.15.0` — check `.nvmrc` before debugging anything else. - **Pre-push hook** (`scripts/pre-push.sh` via simple-git-hooks) gates local pushes; a hook failure is a real gate failure — fix the cause, never `--no-verify`. +## Recovery + +| Symptom | Command | Safety | +|---|---|---| +| Desktop tag job failed | `git tag desktop-v && git push origin desktop-v` | Confirm the tag does not exist first; pushing it starts a public release workflow | +| VS Code lane partially failed | `gh workflow run vscode-release.yml --ref -f expected-version=` | Version is checked; both publishers skip versions already present | +| Native matrix failed before upload | `gh run rerun --failed` | Reuses the same run and commit; do not mix a rebuilt partial asset set | +| CDN is stale | Re-run the failed `Redeploy CDN` or verification job | Do not republish npm; nightly reconciliation remains red until aligned | +| Unknown lane drift | `pnpm release:status` | Read-only; queries npm, GitHub Releases, CDN, Marketplace, and Open VSX | + ## Verification commands ```bash gh run list --workflow=release.yml --branch=main -L 3 # workflow health gh pr list --search 'ci: release packages in:title' --state open -npm view @pythoughts/pythinker-code version # published version -npm view @pythoughts/pythinker-code dist-tags --json -node scripts/release/verify-release-consistency.mjs # local == npm latest -gh release view "@pythoughts/pythinker-code@" # assets + manifest.json +pnpm release:status # all live lanes +npm view @pymodel/pythinker-code dist-tags --json +node scripts/release/verify-release-consistency.mjs +gh release view "@pymodel/pythinker-code@" +gh attestation verify -R PyModel/pythinker-code ``` ## Hard rules (mirror tracked contracts) diff --git a/.github/workflows/_native-build.yml b/.github/workflows/_native-build.yml index 92854b2d..51b8cac7 100644 --- a/.github/workflows/_native-build.yml +++ b/.github/workflows/_native-build.yml @@ -32,6 +32,9 @@ on: permissions: contents: read + id-token: write + attestations: write + artifact-metadata: write jobs: native-bundle: @@ -57,20 +60,20 @@ jobs: env: # secrets.* isn't allowed in `if:` conditions, so resolve the effective # signing gate here (job-level env does allow secrets) and branch on the - # env var in steps below. Absent/empty secrets -> build unsigned + warn, - # not a hard failure; a malformed present secret still fails loudly - # inside macos-keychain-setup (bad base64 / no identity found). - APPLE_SIGNING_AVAILABLE: ${{ inputs.sign-macos && secrets.APPLE_CERTIFICATE_P12 != '' && secrets.APPLE_CERTIFICATE_PASSWORD != '' }} + # env var in steps below. A requested release signature is fail-closed. + APPLE_SIGNING_AVAILABLE: ${{ inputs.sign-macos && secrets.APPLE_CERTIFICATE_P12 != '' && secrets.APPLE_CERTIFICATE_PASSWORD != '' && secrets.APPLE_NOTARIZATION_KEY_P8 != '' && secrets.APPLE_NOTARIZATION_KEY_ID != '' && secrets.APPLE_NOTARIZATION_ISSUER_ID != '' }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 + with: + persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6 - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0 with: node-version-file: .nvmrc cache: 'pnpm' @@ -78,9 +81,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Warn if macOS signing requested but secrets are absent + - name: Require macOS signing secrets if: runner.os == 'macOS' && inputs.sign-macos && env.APPLE_SIGNING_AVAILABLE != 'true' - run: echo "::warning::APPLE_CERTIFICATE_P12/APPLE_CERTIFICATE_PASSWORD not configured — building an unsigned, unnotarized darwin bundle." + run: | + echo "::error::A signed release requires every APPLE_CERTIFICATE_* and APPLE_NOTARIZATION_* secret." + exit 1 - name: Setup macOS keychain (release only) if: runner.os == 'macOS' && env.APPLE_SIGNING_AVAILABLE == 'true' @@ -131,12 +136,18 @@ jobs: - name: Package native artifact run: pnpm --filter @pymodel/pythinker-code run package:native + - name: Attest native zip provenance + if: inputs.sign-macos + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # pinned from v4.2.2 + with: + subject-path: apps/pythinker-code/dist-native/artifacts/pythinker-code-${{ matrix.target }}.zip + - name: Upload native artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from v7 with: name: ${{ inputs.upload-artifact-prefix }}-${{ matrix.target }} retention-days: ${{ inputs.retention-days }} path: | apps/pythinker-code/dist-native/artifacts/pythinker-code-${{ matrix.target }}.zip apps/pythinker-code/dist-native/artifacts/pythinker-code-${{ matrix.target }}.zip.sha256 - if-no-files-found: ignore + if-no-files-found: error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 669eef5a..db0218dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,7 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm run lint - run: pnpm run sherif + - run: pnpm run test:release typecheck: runs-on: ubuntu-latest diff --git a/.github/workflows/manual-native-bundle.yml b/.github/workflows/manual-native-bundle.yml index 3db10ec2..a255a7a2 100644 --- a/.github/workflows/manual-native-bundle.yml +++ b/.github/workflows/manual-native-bundle.yml @@ -5,6 +5,9 @@ on: permissions: contents: read + id-token: write + attestations: write + artifact-metadata: write jobs: build: @@ -13,9 +16,14 @@ jobs: upload-artifact-prefix: pythinker-code-native retention-days: 3 sign-macos: true + # One certificate, one notary key, one set of secret names. These are the + # names desktop-release.yml already ships signed and notarized from; the + # APPLE_CERTIFICATE_*/APPLE_NOTARIZATION_* names this workflow used to read + # were never set on this repository, which is why every darwin CLI bundle + # so far shipped ad-hoc signed behind a green job. secrets: - APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} - APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} - APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + APPLE_CERTIFICATE_P12: ${{ secrets.MAC_CSC_LINK }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 2286a13a..93ac189d 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -20,15 +20,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6 - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0 with: node-version-file: .nvmrc cache: "pnpm" @@ -59,3 +60,74 @@ jobs: exit 0 fi pnpm changeset publish --tag dev --no-git-tag + + release-reconciliation: + name: Reconcile release lanes + if: github.repository_owner == 'PyModel' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0 + with: + node-version-file: .nvmrc + + - name: Check release lane status + id: release-status + env: + GITHUB_TOKEN: ${{ github.token }} + shell: bash + run: | + set +e + node scripts/release/release-status.mjs > "$RUNNER_TEMP/release-status.md" 2>&1 + status=$? + set -e + cat "$RUNNER_TEMP/release-status.md" >> "$GITHUB_STEP_SUMMARY" + echo "exit_code=$status" >> "$GITHUB_OUTPUT" + + - name: Sync release drift issue + env: + GH_TOKEN: ${{ github.token }} + RELEASE_STATUS: ${{ steps.release-status.outputs.exit_code }} + shell: bash + run: | + set -euo pipefail + title='Release lane drift detected' + mapfile -t issues < <(gh issue list \ + --state open \ + --limit 100 \ + --json number,title \ + --jq '.[] | select(.title == "Release lane drift detected") | .number') + + if [ "$RELEASE_STATUS" = '0' ]; then + for issue in "${issues[@]}"; do + gh issue close "$issue" --comment 'Automated release reconciliation is clean.' + done + exit 0 + fi + + body="$RUNNER_TEMP/release-drift-issue.md" + { + echo 'The nightly release reconciliation found drift or could not query a release surface.' + echo + echo "Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + echo + cat "$RUNNER_TEMP/release-status.md" + } > "$body" + + if [ "${#issues[@]}" -eq 0 ]; then + gh issue create --title "$title" --body-file "$body" + else + gh issue edit "${issues[0]}" --title "$title" --body-file "$body" + for issue in "${issues[@]:1}"; do + gh issue close "$issue" --comment "Duplicate of #${issues[0]}." + done + fi + exit "$RELEASE_STATUS" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1035226a..46054b88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,9 +72,21 @@ jobs: if: github.repository_owner == 'PyModel' outputs: packages_published: ${{ steps.changesets.outputs.published }} - extension_version_bumped: ${{ steps.extension-version.outputs.bumped }} - pythinker_native_release: ${{ steps.pythinker-release.outputs.should_publish }} - pythinker_release_tag: ${{ steps.pythinker-release.outputs.tag }} + cli_version_bumped: ${{ steps.lane-versions.outputs.cli_version_bumped }} + cli_version: ${{ steps.lane-versions.outputs.cli_version }} + desktop_version_bumped: ${{ steps.lane-versions.outputs.desktop_version_bumped }} + desktop_version: ${{ steps.lane-versions.outputs.desktop_version }} + extension_version_bumped: ${{ steps.lane-versions.outputs.extension_version_bumped }} + extension_version: ${{ steps.lane-versions.outputs.extension_version }} + # The Changesets output is true on the first publish. The push-boundary + # signal remains true on a rerun, where the idempotent wrapper correctly + # exits without republishing an npm version that is already live. + pythinker_native_release: ${{ steps.pythinker-release.outputs.should_publish == 'true' || steps.lane-versions.outputs.cli_version_bumped == 'true' }} + # `pythinker-release` only runs when changesets publishes, so on the + # rerun path above its tag output is empty. Fall back to the same string + # resolve-release.mjs builds, or publish-native-assets would run + # `gh release view ""`. + pythinker_release_tag: ${{ steps.pythinker-release.outputs.tag || format('@pymodel/pythinker-code@{0}', steps.lane-versions.outputs.cli_version) }} permissions: contents: write pull-requests: write @@ -98,17 +110,14 @@ jobs: - name: Upgrade npm for Trusted Publishing run: npm install -g npm@11 - # Machine-readable signal for the VS Code extension publish gate: the - # release PR merge is a squash whose only meaningful delta for the - # extension is the version field, so compare it against the previous - # commit instead of trusting the commit-message prefix. - - name: Detect extension version bump - id: extension-version - run: | - prev=$(git show 'HEAD^:apps/vscode/package.json' | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).version') - curr=$(node -p 'require("./apps/vscode/package.json").version') - if [ "$prev" = "$curr" ]; then bumped=false; else bumped=true; fi - echo "bumped=$bumped" >> "$GITHUB_OUTPUT" + # `github.event.before` is the exact start of this push. HEAD^ only sees + # the final commit and misses version bumps in a multi-commit push. + - name: Detect release lane version bumps + id: lane-versions + env: + BEFORE_SHA: ${{ github.event.before }} + AFTER_SHA: ${{ github.sha }} + run: node scripts/release/detect-lane-bumps.mjs "$BEFORE_SHA" "$AFTER_SHA" - name: Install dependencies run: pnpm install --frozen-lockfile @@ -171,60 +180,6 @@ jobs: # No NPM_TOKEN on purpose: changesets prefers it over OIDC when set, so # defining it would silently downgrade publishing to a long-lived token. - # apps/desktop is a private workspace package: changesets bumps its - # version but nothing publishes it, and Desktop Release only fires on a - # `desktop-v*` tag. A bump that nobody tags therefore leaves every - # installed desktop client on the previous version with nothing red to - # show for it — which is exactly how 0.2.0 sat unreleased behind 0.1.6. - # Read both versions out of git rather than the working tree: the - # changesets action rewrites package.json in place on the run that opens - # the version PR, and that rewrite is not a release. - # The App token matters: a tag pushed with GITHUB_TOKEN would not start - # Desktop Release, because GitHub refuses to trigger workflows from it. - # A desktop tag that fails to cut costs one manual `git tag`; a failed step - # here would block npm, the Marketplace and the CDN behind it. Never let - # this be the thing that stops a release — but annotate every skip, since - # a silent no-op is the exact failure being fixed. - - name: Cut the desktop release tag on a version bump - continue-on-error: true - env: - GH_TOKEN: ${{ steps.release-bot.outputs.token }} - run: | - set -uo pipefail - # Read the pushed commit by sha, never HEAD. The changesets action - # checks out changeset-release/main and commits the bumped - # package.json files on the run that opens the version PR, so by the - # time this step runs HEAD can be a bump that is not a release — and - # tagging it would burn the next real version's tag name, which the - # already-exists check below would then treat as done. - read_version() { git show "$1:apps/desktop/package.json" 2>/dev/null | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).version' 2>/dev/null; } - prev="$(read_version "${GITHUB_SHA}^" || true)" - curr="$(read_version "${GITHUB_SHA}" || true)" - if [ -z "$prev" ] || [ -z "$curr" ] || [ "$prev" = "$curr" ]; then - echo "::notice::Desktop version unchanged or unreadable (prev='${prev}' curr='${curr}'); no tag cut." - exit 0 - fi - tag="desktop-v${curr}" - # A tag on this commit is a re-run. A tag on a different commit is an - # anomaly that would otherwise silently swallow this release. - existing="$(git ls-remote --tags origin "refs/tags/${tag}" "refs/tags/${tag}^{}" | - awk '$2 ~ /\^\{\}$/ { peeled=$1 } $2 !~ /\^\{\}$/ { direct=$1 } END { print (peeled ? peeled : direct) }')" - if [ -n "$existing" ]; then - if [ "$existing" = "$GITHUB_SHA" ]; then - echo "::notice::${tag} already points here; nothing to do." - else - echo "::error::${tag} exists at ${existing}, not ${GITHUB_SHA}. Desktop ${curr} will not ship until that is resolved." - fi - exit 0 - fi - if ! gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ - -f ref="refs/tags/${tag}" \ - -f sha="${GITHUB_SHA}" > /dev/null; then - echo "::error::Could not cut ${tag}. Desktop ${curr} will not ship until someone pushes it: git tag ${tag} ${GITHUB_SHA} && git push origin ${tag}" - exit 1 - fi - echo "Cut ${tag} at ${GITHUB_SHA} (desktop ${prev} -> ${curr})." - # Release cadence switch. With this on, the version PR merges itself once # every required check passes, so one merge to main becomes one release # and the version tracks each change instead of collapsing a backlog of @@ -329,83 +284,79 @@ jobs: env: CHANGESETS_PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }} - # The VS Code extension is a private workspace package: changesets bumps its - # version but never publishes it to npm, so it ships from here instead. Both - # publish scripts skip packages that already exist in the registry, so this - # job is a no-op on releases that did not touch the extension. Extension-only - # releases publish nothing to npm, so the gate also covers a version bump. - publish-vscode-extension: - timeout-minutes: 45 - name: Publish VS Code extension + # Desktop tagging is isolated from npm and VS Code. A tag failure turns this + # workflow red without preventing the other lanes from reporting their state. + cut-desktop-tag: + timeout-minutes: 10 + name: Cut desktop release tag needs: release if: >- - needs.release.outputs.packages_published == 'true' - || needs.release.outputs.extension_version_bumped == 'true' + needs.release.outputs.desktop_version_bumped == 'true' + && vars.RELEASE_LANE_DESKTOP != 'disabled' runs-on: ubuntu-latest permissions: contents: read steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 - - - name: Setup pnpm - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6 - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0 with: - node-version-file: .nvmrc - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Generate Pythinker Code built-in catalog - shell: bash - run: | - CATALOG_FILE="$RUNNER_TEMP/pythinker-code-built-in-catalog.json" - node apps/pythinker-code/scripts/update-catalog.mjs --out "$CATALOG_FILE" - echo "PYTHINKER_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV" - - - name: Build workspace packages - run: pnpm build + fetch-depth: 0 + persist-credentials: false - # Packages all six platform targets and runs the VSIX audit on each one. - # A failure here must stop the job before anything reaches a registry. - - name: Package and verify VSIX targets - run: pnpm --filter pythinker run package:platform + # GITHUB_TOKEN-created tags cannot trigger another workflow. The App + # token makes `desktop-v*` start desktop-release.yml. + - name: Mint release-bot token + id: release-bot + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # pinned from v2 + with: + app-id: ${{ vars.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + permission-contents: write - - name: Publish to the Visual Studio Marketplace + - name: Cut required desktop tag env: - VSCE_PAT: ${{ secrets.VSCE_PAT }} + GH_TOKEN: ${{ steps.release-bot.outputs.token }} + DESKTOP_VERSION: ${{ needs.release.outputs.desktop_version }} run: | - if [ -z "$VSCE_PAT" ]; then - echo "::warning::VSCE_PAT secret not set — skipping Marketplace publish." + set -euo pipefail + tag="desktop-v${DESKTOP_VERSION}" + existing="$(git ls-remote --tags origin "refs/tags/${tag}" "refs/tags/${tag}^{}" | + awk '$2 ~ /\^\{\}$/ { peeled=$1 } $2 !~ /\^\{\}$/ { direct=$1 } END { print (peeled ? peeled : direct) }')" + if [ -n "$existing" ]; then + if [ "$existing" != "$GITHUB_SHA" ]; then + echo "::error::${tag} exists at ${existing}, not ${GITHUB_SHA}." + exit 1 + fi + echo "::notice::${tag} already points at ${GITHUB_SHA}; nothing to do." exit 0 fi - pnpm --filter pythinker run publish:vsix - - # Open VSX serves Cursor / VSCodium / Windsurf. A failure here must not - # undo an already-successful Marketplace publish, so it only warns. - - name: Publish to Open VSX - continue-on-error: true - env: - OVSX_PAT: ${{ secrets.OVSX_PAT }} - run: | - if [ -z "$OVSX_PAT" ]; then - echo "::warning::OVSX_PAT secret not set — skipping Open VSX publish." - exit 0 + if ! gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f ref="refs/tags/${tag}" \ + -f sha="${GITHUB_SHA}" > /dev/null; then + echo "::error::Could not cut ${tag}. Recovery: git tag ${tag} ${GITHUB_SHA} && git push origin ${tag}" + exit 1 fi - pnpm --filter pythinker run publish:ovsx + echo "Cut ${tag} at ${GITHUB_SHA}." - - name: Upload VSIX artifacts - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from v7 - with: - name: pythinker-code-vsix - path: apps/vscode/artifacts/vsix/*.vsix - retention-days: 7 - if-no-files-found: error + # The extension lane runs only when its own version changes. The reusable + # workflow also supports a version-checked manual recovery run. + publish-vscode-extension: + name: Publish VS Code extension + needs: release + if: >- + needs.release.outputs.extension_version_bumped == 'true' + && vars.RELEASE_LANE_VSCODE != 'disabled' + uses: ./.github/workflows/vscode-release.yml + permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write + with: + expected-version: ${{ needs.release.outputs.extension_version }} + secrets: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} # code.pythinker.com redeploys via Dokploy autodeploy on push to main (app # Pythinker/code builds apps/site/Dockerfile from the repo). That autodeploy @@ -438,10 +389,9 @@ jobs: if: >- always() && needs.release.result == 'success' - && (needs.release.outputs.pythinker_native_release != 'true' - || needs.publish-native-assets.result == 'success') - && (needs.release.outputs.packages_published == 'true' - || startsWith(github.event.head_commit.message, 'ci: release packages')) + && needs.release.outputs.pythinker_native_release == 'true' + && needs.publish-native-assets.result == 'success' + && vars.RELEASE_LANE_CDN != 'disabled' runs-on: ubuntu-latest steps: - name: Trigger Dokploy rebuild @@ -449,18 +399,16 @@ jobs: WEBHOOK: ${{ secrets.DOKPLOY_CDN_DEPLOY_WEBHOOK }} run: | if [ -z "$WEBHOOK" ]; then - echo "::warning::DOKPLOY_CDN_DEPLOY_WEBHOOK not set — skipping CDN redeploy." - exit 0 + echo "::error::DOKPLOY_CDN_DEPLOY_WEBHOOK is required. Configure it or set RELEASE_LANE_CDN=disabled." + exit 1 fi # The URL is itself the deploy credential, so never send it over a - # scheme that puts it on the wire in cleartext. Warn rather than fail: - # verify-cdn-release runs only if this job succeeds, and failing here - # would drop the consistency gate instead of tripping it. + # scheme that puts it on the wire in cleartext. case "$WEBHOOK" in https://*) ;; *) - echo "::warning::DOKPLOY_CDN_DEPLOY_WEBHOOK is not an https:// URL — refusing to send the deploy credential in cleartext." - exit 0 + echo "::error::DOKPLOY_CDN_DEPLOY_WEBHOOK must use https://." + exit 1 ;; esac # The webhook reads the branch from the body, but only when the @@ -514,8 +462,9 @@ jobs: if: >- always() && needs.release.result == 'success' - && (needs.release.outputs.packages_published == 'true' - || startsWith(github.event.head_commit.message, 'ci: release packages')) + && vars.RELEASE_LANE_CDN != 'disabled' + && (needs.release.outputs.pythinker_native_release == 'true' + || needs.release.outputs.cli_version_bumped == 'true') runs-on: ubuntu-latest steps: - name: Checkout @@ -540,7 +489,9 @@ jobs: contents: read name: Update Homebrew tap needs: release - if: needs.release.outputs.packages_published == 'true' + if: >- + needs.release.outputs.pythinker_native_release == 'true' + && vars.RELEASE_LANE_BREW != 'disabled' runs-on: ubuntu-latest steps: - name: Checkout @@ -567,13 +518,7 @@ jobs: - name: Bump formula env: TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }} - run: | - # The token comes from the App installation, not a PAT. - if [ -z "$TAP_GITHUB_TOKEN" ]; then - echo "TAP_GITHUB_TOKEN secret not set — skipping tap update" >&2 - exit 0 - fi - node scripts/release/update-brew-formula.mjs + run: node scripts/release/update-brew-formula.mjs deploy-docs: name: Deploy docs @@ -588,22 +533,29 @@ jobs: native-artifacts: name: Native release artifact needs: release - # Matches the `contents: read` that _native-build.yml declares for itself; - # a called workflow cannot exceed what the calling job grants. + # A called workflow cannot elevate the permissions granted here. permissions: contents: read + id-token: write + attestations: write + artifact-metadata: write if: needs.release.outputs.pythinker_native_release == 'true' uses: ./.github/workflows/_native-build.yml with: upload-artifact-prefix: pythinker-code-native retention-days: 7 sign-macos: true + # One certificate, one notary key, one set of secret names. These are the + # names desktop-release.yml already ships signed and notarized from; the + # APPLE_CERTIFICATE_*/APPLE_NOTARIZATION_* names this workflow used to read + # were never set on this repository, which is why every darwin CLI bundle + # so far shipped ad-hoc signed behind a green job. secrets: - APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} - APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} - APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + APPLE_CERTIFICATE_P12: ${{ secrets.MAC_CSC_LINK }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER }} publish-native-assets: timeout-minutes: 15 @@ -688,3 +640,48 @@ jobs: exit 1 fi gh release upload "$RELEASE_TAG" "${absent[@]}" + + release-summary: + name: Release lane summary + if: always() && github.repository_owner == 'PyModel' + needs: + - release + - cut-desktop-tag + - publish-vscode-extension + - native-artifacts + - publish-native-assets + - redeploy-cdn + - verify-cdn-release + - update-brew-tap + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0 + with: + node-version-file: .nvmrc + + - name: Render and enforce lane summary + env: + RELEASE_RESULT: ${{ needs.release.result }} + CLI_EXPECTED: ${{ needs.release.outputs.cli_version_bumped == 'true' || needs.release.outputs.pythinker_native_release == 'true' }} + CLI_PUBLISHED: ${{ needs.release.outputs.pythinker_native_release }} + NATIVE_RESULT: ${{ needs.publish-native-assets.result }} + CDN_ENABLED: ${{ vars.RELEASE_LANE_CDN != 'disabled' }} + CDN_DEPLOY_RESULT: ${{ needs.redeploy-cdn.result }} + CDN_VERIFY_RESULT: ${{ needs.verify-cdn-release.result }} + BREW_ENABLED: ${{ vars.RELEASE_LANE_BREW != 'disabled' }} + BREW_RESULT: ${{ needs.update-brew-tap.result }} + DESKTOP_EXPECTED: ${{ needs.release.outputs.desktop_version_bumped }} + DESKTOP_ENABLED: ${{ vars.RELEASE_LANE_DESKTOP != 'disabled' }} + DESKTOP_RESULT: ${{ needs.cut-desktop-tag.result }} + VSCODE_EXPECTED: ${{ needs.release.outputs.extension_version_bumped }} + VSCODE_ENABLED: ${{ vars.RELEASE_LANE_VSCODE != 'disabled' }} + VSCODE_RESULT: ${{ needs.publish-vscode-extension.result }} + run: node scripts/release/render-summary.mjs diff --git a/.github/workflows/vscode-release.yml b/.github/workflows/vscode-release.yml new file mode 100644 index 00000000..b34e48c3 --- /dev/null +++ b/.github/workflows/vscode-release.yml @@ -0,0 +1,106 @@ +name: VS Code Release + +on: + workflow_call: + inputs: + expected-version: + description: Version that must be present in apps/vscode/package.json + required: true + type: string + secrets: + VSCE_PAT: + required: true + OVSX_PAT: + required: true + workflow_dispatch: + inputs: + expected-version: + description: Version that must be present on the selected ref + required: true + type: string + +permissions: {} + +jobs: + publish: + name: Publish VS Code extension + timeout-minutes: 45 + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 + with: + persist-credentials: false + + - name: Require the requested version and publish credentials + env: + EXPECTED_VERSION: ${{ inputs.expected-version }} + VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: | + set -euo pipefail + actual="$(node -p 'require("./apps/vscode/package.json").version')" + if [ "$actual" != "$EXPECTED_VERSION" ]; then + echo "::error::Requested VS Code version ${EXPECTED_VERSION}, but this ref contains ${actual}." + exit 1 + fi + if [ -z "$VSCE_PAT" ] || [ -z "$OVSX_PAT" ]; then + echo "::error::VSCE_PAT and OVSX_PAT are required. Configure both secrets or set RELEASE_LANE_VSCODE=disabled." + exit 1 + fi + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0 + with: + node-version-file: .nvmrc + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Pythinker Code built-in catalog + shell: bash + run: | + CATALOG_FILE="$RUNNER_TEMP/pythinker-code-built-in-catalog.json" + node apps/pythinker-code/scripts/update-catalog.mjs --out "$CATALOG_FILE" + echo "PYTHINKER_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV" + + - name: Build workspace packages + run: pnpm build + + - name: Package and verify VSIX targets + run: pnpm --filter pythinker run package:platform + + - name: Attest VSIX provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # pinned from v4.2.2 + with: + subject-path: apps/vscode/artifacts/vsix/*.vsix + + - name: Publish to the Visual Studio Marketplace + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + run: pnpm --filter pythinker run publish:vsix + + # A registry can fail after the other succeeds. Both publishers skip an + # existing version, so a red partial run is safe to dispatch again. + - name: Publish to Open VSX + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: pnpm --filter pythinker run publish:ovsx + + - name: Upload VSIX artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from v7 + with: + name: pythinker-code-vsix-${{ inputs.expected-version }} + path: apps/vscode/artifacts/vsix/*.vsix + retention-days: 7 + if-no-files-found: error diff --git a/package.json b/package.json index bc589a32..a859a866 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "test:coverage": "vitest run --coverage", "clean": "pnpm -r run clean", "changeset": "changeset", + "release:status": "node scripts/release/release-status.mjs", + "test:release": "node --test scripts/release/*.test.mjs", "version": "changeset version", "version:release": "changeset version", "publish": "pnpm run typecheck && pnpm run lint && pnpm run sherif && pnpm run test && pnpm run build && pnpm run lint:pkg && changeset publish", diff --git a/scripts/release/detect-lane-bumps.mjs b/scripts/release/detect-lane-bumps.mjs new file mode 100644 index 00000000..fb5ab7db --- /dev/null +++ b/scripts/release/detect-lane-bumps.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { appendFileSync } from 'node:fs'; + +const semver = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/u; + +const lanes = { + cli: 'apps/pythinker-code/package.json', + desktop: 'apps/desktop/package.json', + extension: 'apps/vscode/package.json', +}; + +function readVersion(ref, path, cwd) { + try { + const source = execFileSync('git', ['show', `${ref}:${path}`], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const version = JSON.parse(source).version; + if (typeof version !== 'string' || semver.exec(version)?.[0] !== version) throw new Error('invalid version'); + return version; + } catch (error) { + throw new Error(`Cannot read release lane version from ${ref}:${path}.`, { cause: error }); + } +} + +export function detectLaneBumps({ before, after, cwd = process.cwd() }) { + if (typeof before !== 'string' || before === '' || typeof after !== 'string' || after === '') { + throw new Error('Both before and after Git revisions are required.'); + } + + return Object.fromEntries(Object.entries(lanes).map(([name, path]) => { + const beforeVersion = readVersion(before, path, cwd); + const afterVersion = readVersion(after, path, cwd); + return [name, { + before: beforeVersion, + after: afterVersion, + bumped: beforeVersion !== afterVersion, + }]; + })); +} + +function writeOutputs(result, outputPath) { + const lines = [ + `cli_version_bumped=${String(result.cli.bumped)}`, + `cli_version=${result.cli.after}`, + `desktop_version_bumped=${String(result.desktop.bumped)}`, + `desktop_version=${result.desktop.after}`, + `extension_version_bumped=${String(result.extension.bumped)}`, + `extension_version=${result.extension.after}`, + ]; + if (outputPath !== undefined && outputPath !== '') appendFileSync(outputPath, `${lines.join('\n')}\n`); + return lines; +} + +function main() { + const [before, after, ...rest] = process.argv.slice(2); + if (before === undefined || after === undefined || rest.length > 0) { + throw new Error('Usage: node scripts/release/detect-lane-bumps.mjs '); + } + const result = detectLaneBumps({ before, after }); + for (const line of writeOutputs(result, process.env.GITHUB_OUTPUT)) process.stdout.write(`${line}\n`); +} + +if (process.argv[1] === import.meta.filename) { + try { + main(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/release/detect-lane-bumps.test.mjs b/scripts/release/detect-lane-bumps.test.mjs new file mode 100644 index 00000000..7938ed85 --- /dev/null +++ b/scripts/release/detect-lane-bumps.test.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; + +import { detectLaneBumps } from './detect-lane-bumps.mjs'; + +const packagePaths = { + cli: 'apps/pythinker-code/package.json', + desktop: 'apps/desktop/package.json', + extension: 'apps/vscode/package.json', +}; + +async function writePackage(root, path, version) { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, `${JSON.stringify({ version })}\n`); +} + +function git(root, args) { + return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); +} + +function commit(root, message) { + git(root, ['add', '.']); + git(root, [ + '-c', 'core.hooksPath=/dev/null', + '-c', 'user.name=Release Test', + '-c', 'user.email=release@example.test', + 'commit', + '-m', message, + ]); + return git(root, ['rev-parse', 'HEAD']); +} + +void test('detects a lane bump anywhere in the pushed commit range', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'release-lanes-')); + t.after(() => rm(root, { recursive: true, force: true })); + git(root, ['init', '-b', 'main']); + + await Promise.all(Object.values(packagePaths).map((path) => writePackage(root, path, '1.0.0'))); + const before = commit(root, 'initial'); + + await writePackage(root, packagePaths.extension, '1.1.0'); + commit(root, 'bump extension'); + await writeFile(join(root, 'README.md'), 'unrelated final commit\n'); + const after = commit(root, 'unrelated follow-up'); + + const result = detectLaneBumps({ before, after, cwd: root }); + assert.deepEqual(result, { + cli: { before: '1.0.0', after: '1.0.0', bumped: false }, + desktop: { before: '1.0.0', after: '1.0.0', bumped: false }, + extension: { before: '1.0.0', after: '1.1.0', bumped: true }, + }); +}); + +void test('fails closed when the push boundary cannot be read', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'release-lanes-')); + t.after(() => rm(root, { recursive: true, force: true })); + git(root, ['init', '-b', 'main']); + await Promise.all(Object.values(packagePaths).map((path) => writePackage(root, path, '1.0.0'))); + const after = commit(root, 'initial'); + + assert.throws( + () => detectLaneBumps({ before: 'missing-ref', after, cwd: root }), + /Cannot read release lane version/, + ); +}); + +void test('rejects a version that could inject a GitHub output line', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'release-lanes-')); + t.after(() => rm(root, { recursive: true, force: true })); + git(root, ['init', '-b', 'main']); + await Promise.all(Object.values(packagePaths).map((path) => writePackage(root, path, '1.0.0'))); + const before = commit(root, 'initial'); + await writePackage(root, packagePaths.extension, '1.1.0\nforged=true'); + const after = commit(root, 'invalid version'); + + assert.throws( + () => detectLaneBumps({ before, after, cwd: root }), + /Cannot read release lane version/, + ); +}); diff --git a/scripts/release/release-status.mjs b/scripts/release/release-status.mjs new file mode 100644 index 00000000..ede2cf8d --- /dev/null +++ b/scripts/release/release-status.mjs @@ -0,0 +1,283 @@ +#!/usr/bin/env node + +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const cliPackageName = '@pymodel/pythinker-code'; +const semver = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/u; + +export const nativeTargets = Object.freeze([ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'win32-arm64', + 'win32-x64', +]); + +export const expectedCliAssets = Object.freeze([ + 'manifest.json', + ...nativeTargets.flatMap((target) => [ + `pythinker-code-${target}.zip`, + `pythinker-code-${target}.zip.sha256`, + ]), +]); + +const packagePaths = { + cli: 'apps/pythinker-code/package.json', + desktop: 'apps/desktop/package.json', + extension: 'apps/vscode/package.json', +}; + +async function readVersion(rootDir, path) { + const parsed = JSON.parse(await readFile(resolve(rootDir, path), 'utf8')); + if (typeof parsed.version !== 'string' || semver.exec(parsed.version)?.[0] !== parsed.version) { + throw new Error(`${path} has no valid release version.`); + } + return parsed.version; +} + +function githubHeaders(token) { + return { + accept: 'application/vnd.github+json', + 'x-github-api-version': '2022-11-28', + ...(token === undefined || token === '' ? {} : { authorization: `Bearer ${token}` }), + }; +} + +async function fetchJson(fetchImpl, url, label, init = {}) { + const response = await fetchImpl(url, { + ...init, + headers: { + 'user-agent': 'pythinker-release-status', + ...init.headers, + }, + signal: AbortSignal.timeout(20_000), + }); + if (!response.ok) throw new Error(`${label} returned HTTP ${response.status}.`); + try { + return await response.json(); + } catch (error) { + throw new Error(`${label} returned invalid JSON.`, { cause: error }); + } +} + +function validVersion(value) { + return typeof value === 'string' && semver.exec(value)?.[0] === value ? value : undefined; +} + +function settledValue(result, select) { + if (result.status === 'rejected') return { error: messageOf(result.reason) }; + try { + return select(result.value); + } catch (error) { + return { error: messageOf(error) }; + } +} + +function messageOf(error) { + return (error instanceof Error ? error.message : String(error)).replaceAll(/\s+/gu, ' ').trim(); +} + +function targetCoverage(values) { + const present = new Set(values); + const missing = nativeTargets.filter((target) => !present.has(target)); + return { present: nativeTargets.length - missing.length, missing }; +} + +function missingDetail(missing) { + return missing.length === 0 ? '' : `; missing ${missing.join(', ')}`; +} + +export async function collectReleaseStatus({ + rootDir = resolve(import.meta.dirname, '../..'), + fetchImpl = globalThis.fetch, + githubToken = process.env.GITHUB_TOKEN, +} = {}) { + const [cliVersion, desktopVersion, extensionVersion] = await Promise.all([ + readVersion(rootDir, packagePaths.cli), + readVersion(rootDir, packagePaths.desktop), + readVersion(rootDir, packagePaths.extension), + ]); + const cliTag = `${cliPackageName}@${cliVersion}`; + const github = githubHeaders(githubToken); + + const [npmResult, cdnResult, cliReleaseResult, desktopResult, marketplaceResult, openVsxResult] = + await Promise.allSettled([ + fetchJson( + fetchImpl, + 'https://registry.npmjs.org/-/package/%40pymodel%2Fpythinker-code/dist-tags', + 'npm dist-tags', + ), + fetchJson( + fetchImpl, + 'https://code.pythinker.com/pythinker-code/latest.json', + 'CDN manifest', + ), + fetchJson( + fetchImpl, + `https://api.github.com/repos/PyModel/pythinker-code/releases/tags/${encodeURIComponent(cliTag)}`, + 'CLI GitHub release', + { headers: github }, + ), + fetchJson( + fetchImpl, + 'https://api.github.com/repos/PyModel/pythinker-desktop-releases/releases/latest', + 'desktop GitHub release', + { headers: github }, + ), + fetchJson( + fetchImpl, + 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery', + 'Visual Studio Marketplace', + { + method: 'POST', + headers: { + accept: 'application/json;api-version=7.2-preview.1', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + filters: [{ criteria: [{ filterType: 7, value: 'pymodel.pythinker' }] }], + flags: 914, + }), + }, + ), + fetchJson( + fetchImpl, + 'https://open-vsx.org/api/PyModel/pythinker/latest', + 'Open VSX', + ), + ]); + + const npm = settledValue(npmResult, (value) => ({ version: validVersion(value.latest) })); + const cdn = settledValue(cdnResult, (value) => ({ + version: validVersion(value.version), + coverage: targetCoverage(Object.keys(value.platforms ?? {})), + })); + const cliRelease = settledValue(cliReleaseResult, (value) => { + const assets = Array.isArray(value.assets) ? value.assets.map((asset) => asset.name) : []; + const missing = expectedCliAssets.filter((name) => !assets.includes(name)); + return { tag: value.tag_name, assets, missing }; + }); + const desktop = settledValue(desktopResult, (value) => ({ + version: typeof value.tag_name === 'string' ? validVersion(value.tag_name.replace(/^v/u, '')) : undefined, + assets: Array.isArray(value.assets) ? value.assets.length : 0, + })); + const marketplace = settledValue(marketplaceResult, (value) => { + const versions = value.results?.[0]?.extensions?.[0]?.versions; + if (!Array.isArray(versions) || versions.length === 0) throw new Error('No extension versions found.'); + const version = validVersion(versions[0].version); + const targets = versions + .filter((entry) => entry.version === version) + .map((entry) => entry.targetPlatform); + return { version, coverage: targetCoverage(targets) }; + }); + const openVsx = settledValue(openVsxResult, (value) => ({ + version: validVersion(value.version), + coverage: targetCoverage(Object.keys(value.downloads ?? {})), + })); + + const cliMissing = cliRelease.missing ?? expectedCliAssets; + const cliOk = npm.version === cliVersion + && cliRelease.tag === cliTag + && cliMissing.length === 0 + && npm.error === undefined + && cliRelease.error === undefined; + const rows = [ + { + lane: 'npm CLI', + expected: cliVersion, + observed: npm.version ?? 'unavailable', + ok: cliOk, + details: npm.error ?? cliRelease.error + ?? `GitHub assets ${(cliRelease.assets ?? []).length}/${expectedCliAssets.length}${missingDetail(cliMissing)}`, + }, + { + lane: 'CDN', + expected: cliVersion, + observed: cdn.version ?? 'unavailable', + ok: cdn.version === cliVersion && cdn.coverage?.missing.length === 0 && cdn.error === undefined, + details: cdn.error + ?? `platforms ${cdn.coverage?.present ?? 0}/${nativeTargets.length}${missingDetail(cdn.coverage?.missing ?? nativeTargets)}`, + }, + { + lane: 'Desktop', + expected: desktopVersion, + observed: desktop.version ?? 'unavailable', + ok: desktop.version === desktopVersion && desktop.error === undefined, + details: desktop.error ?? `release assets ${desktop.assets}`, + }, + { + lane: 'VS Marketplace', + expected: extensionVersion, + observed: marketplace.version ?? 'unavailable', + ok: marketplace.version === extensionVersion + && marketplace.coverage?.missing.length === 0 + && marketplace.error === undefined, + details: marketplace.error + ?? `targets ${marketplace.coverage?.present ?? 0}/${nativeTargets.length}${missingDetail(marketplace.coverage?.missing ?? nativeTargets)}`, + }, + { + lane: 'Open VSX', + expected: extensionVersion, + observed: openVsx.version ?? 'unavailable', + ok: openVsx.version === extensionVersion + && openVsx.coverage?.missing.length === 0 + && openVsx.error === undefined, + details: openVsx.error + ?? `targets ${openVsx.coverage?.present ?? 0}/${nativeTargets.length}${missingDetail(openVsx.coverage?.missing ?? nativeTargets)}`, + }, + ]; + + return { + ok: rows.every((row) => row.ok), + generatedAt: new Date().toISOString(), + rows, + }; +} + +function markdownCell(value) { + return String(value).replaceAll('|', '\\|').replaceAll(/\r?\n/gu, ' '); +} + +export function renderReleaseStatus(result) { + return [ + '# Release lane status', + '', + `Generated: ${result.generatedAt}`, + '', + '| Lane | Expected | Observed | Status | Details |', + '|---|---:|---:|---|---|', + ...result.rows.map((row) => [ + markdownCell(row.lane), + markdownCell(row.expected), + markdownCell(row.observed), + row.ok ? 'PASS' : 'FAIL', + markdownCell(row.details), + ].join(' | ').replace(/^/u, '| ').replace(/$/u, ' |')), + '', + ].join('\n'); +} + +async function main() { + const args = process.argv.slice(2); + if (args.some((argument) => !['--json', '--help', '-h'].includes(argument))) { + throw new Error('Usage: node scripts/release/release-status.mjs [--json]'); + } + if (args.includes('--help') || args.includes('-h')) { + process.stdout.write('Usage: node scripts/release/release-status.mjs [--json]\n'); + return; + } + const result = await collectReleaseStatus(); + process.stdout.write(`${args.includes('--json') ? JSON.stringify(result, null, 2) : renderReleaseStatus(result)}\n`); + if (!result.ok) process.exitCode = 1; +} + +if (process.argv[1] === import.meta.filename) { + try { + await main(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 2; + } +} diff --git a/scripts/release/release-status.test.mjs b/scripts/release/release-status.test.mjs new file mode 100644 index 00000000..911d41c0 --- /dev/null +++ b/scripts/release/release-status.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; + +import { + collectReleaseStatus, + expectedCliAssets, + nativeTargets, + renderReleaseStatus, +} from './release-status.mjs'; + +async function writePackage(root, path, version) { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, `${JSON.stringify({ version })}\n`); +} + +async function fixtureRoot(t) { + const root = await mkdtemp(join(tmpdir(), 'release-status-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writePackage(root, 'apps/pythinker-code/package.json', '1.3.0'); + await writePackage(root, 'apps/desktop/package.json', '0.2.1'); + await writePackage(root, 'apps/vscode/package.json', '0.9.5'); + return root; +} + +function json(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function fixtureFetch({ cliAssets = expectedCliAssets } = {}) { + return async (input) => { + const url = new URL(String(input)); + if (url.hostname === 'registry.npmjs.org') return json({ latest: '1.3.0' }); + if (url.hostname === 'code.pythinker.com') { + return json({ + version: '1.3.0', + platforms: Object.fromEntries(nativeTargets.map((target) => [target, {}])), + }); + } + if (url.hostname === 'api.github.com' && url.pathname.startsWith('/repos/PyModel/pythinker-code/releases/tags/')) { + return json({ + tag_name: '@pymodel/pythinker-code@1.3.0', + assets: cliAssets.map((name) => ({ name })), + }); + } + if (url.hostname === 'api.github.com' && url.pathname === '/repos/PyModel/pythinker-desktop-releases/releases/latest') { + return json({ tag_name: 'v0.2.1', assets: [{ name: 'latest.yml' }] }); + } + if (url.hostname === 'open-vsx.org') { + return json({ + version: '0.9.5', + downloads: Object.fromEntries(nativeTargets.map((target) => [target, `https://${target}`])), + }); + } + if (url.hostname === 'marketplace.visualstudio.com') { + return json({ + results: [{ + extensions: [{ + versions: nativeTargets.map((target) => ({ version: '0.9.5', targetPlatform: target })), + }], + }], + }); + } + return json({ message: `Unexpected URL: ${url}` }, 404); + }; +} + +void test('fixture routing rejects trusted hostnames outside the URL host', async () => { + const response = await fixtureFetch()( + 'https://example.test/registry.npmjs.org/code.pythinker.com/open-vsx.org/marketplace.visualstudio.com', + ); + + assert.equal(response.status, 404); +}); + +void test('reports all live release lanes aligned', async (t) => { + const rootDir = await fixtureRoot(t); + const result = await collectReleaseStatus({ rootDir, fetchImpl: fixtureFetch() }); + + assert.equal(result.ok, true); + assert.equal(result.rows.every((row) => row.ok), true); + assert.match(renderReleaseStatus(result), /\| npm CLI \| 1\.3\.0 \| 1\.3\.0 \| PASS \|/u); +}); + +void test('fails when a published CLI release is missing one required asset', async (t) => { + const rootDir = await fixtureRoot(t); + const result = await collectReleaseStatus({ + rootDir, + fetchImpl: fixtureFetch({ cliAssets: expectedCliAssets.filter((name) => name !== 'manifest.json') }), + }); + + assert.equal(result.ok, false); + const cli = result.rows.find((row) => row.lane === 'npm CLI'); + assert.equal(cli?.ok, false); + assert.match(cli?.details ?? '', /missing manifest\.json/u); +}); diff --git a/scripts/release/release-workflows.test.mjs b/scripts/release/release-workflows.test.mjs new file mode 100644 index 00000000..b57de8bc --- /dev/null +++ b/scripts/release/release-workflows.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +const root = resolve(import.meta.dirname, '../..'); +const read = (path) => readFileSync(resolve(root, path), 'utf8'); + +void test('release workflow uses full push-boundary lane signals and isolated jobs', () => { + const workflow = read('.github/workflows/release.yml'); + const desktopJob = workflow.slice( + workflow.indexOf(' cut-desktop-tag:'), + workflow.indexOf(' publish-vscode-extension:'), + ); + assert.match(workflow, /github\.event\.before/u); + assert.match(workflow, /pythinker_native_release:.*cli_version_bumped/u); + assert.match(workflow, /^ cut-desktop-tag:/mu); + assert.match(workflow, /uses: \.\/\.github\/workflows\/vscode-release\.yml/u); + assert.match(workflow, /^ release-summary:/mu); + assert.doesNotMatch(workflow, /HEAD\^:apps\/vscode\/package\.json/u); + assert.match(desktopJob, /permission-contents: write/u); + assert.match(workflow, /pythinker_release_tag: \$\{\{ steps\.pythinker-release\.outputs\.tag \|\|/u); + assert.match(workflow, /APPLE_CERTIFICATE_P12: \$\{\{ secrets\.MAC_CSC_LINK \}\}/u); + assert.match(workflow, /APPLE_NOTARIZATION_KEY_P8: \$\{\{ secrets\.APPLE_API_KEY_P8 \}\}/u); +}); + +void test('VS Code release supports isolated recovery and attests verified VSIX files', () => { + const workflow = read('.github/workflows/vscode-release.yml'); + assert.match(workflow, /^ workflow_call:/mu); + assert.match(workflow, /^ workflow_dispatch:/mu); + assert.match(workflow, /actions\/attest@[0-9a-f]{40}/u); + assert.match(workflow, /artifact-metadata: write/u); + assert.doesNotMatch(workflow, /continue-on-error: true/u); +}); + +void test('native releases fail without requested signing and attest each zip', () => { + const workflow = read('.github/workflows/_native-build.yml'); + const manualWorkflow = read('.github/workflows/manual-native-bundle.yml'); + assert.match(workflow, /Require macOS signing secrets/u); + assert.match(workflow, /actions\/attest@[0-9a-f]{40}/u); + assert.match(workflow, /if-no-files-found: error/u); + assert.doesNotMatch(workflow, /uses: actions\/(checkout|setup-node|upload-artifact)@v\d+/u); + assert.equal(workflow.match(/persist-credentials: false/gu)?.length, 1); + assert.match(manualWorkflow, /id-token: write/u); + assert.match(manualWorkflow, /attestations: write/u); + assert.match(manualWorkflow, /artifact-metadata: write/u); +}); + +void test('nightly reconciliation maintains one release drift issue', () => { + const workflow = read('.github/workflows/nightly.yml'); + assert.match(workflow, /scripts\/release\/release-status\.mjs/u); + assert.match(workflow, /Release lane drift detected/u); + assert.match(workflow, /issues: write/u); + assert.doesNotMatch(workflow, /uses: actions\/(checkout|setup-node)@v\d+/u); + assert.equal(workflow.match(/persist-credentials: false/gu)?.length, 2); +}); diff --git a/scripts/release/render-summary.mjs b/scripts/release/render-summary.mjs new file mode 100644 index 00000000..cba1d8f5 --- /dev/null +++ b/scripts/release/render-summary.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +import { appendFile } from 'node:fs/promises'; + +const enabled = (value) => value === 'true'; +const expected = (value) => value === 'true'; +const succeeded = (value) => value === 'success'; +const displayResult = (value) => value === undefined || value === '' ? 'missing' : value; + +function laneRow(name, isExpected, isEnabled, result, hasProvenance = false) { + if (!isEnabled) { + return { lane: name, expectation: 'disabled', status: 'SKIP', provenance: 'n/a', details: 'explicit opt-out', ok: true }; + } + if (!isExpected) { + return { lane: name, expectation: 'not expected', status: 'SKIP', provenance: 'n/a', details: 'version unchanged', ok: true }; + } + return { + lane: name, + expectation: 'expected', + status: succeeded(result) ? 'PASS' : 'FAIL', + provenance: hasProvenance && succeeded(result) ? 'attested' : hasProvenance ? 'missing' : 'n/a', + details: `job=${displayResult(result)}`, + ok: succeeded(result), + }; +} + +function markdownCell(value) { + return String(value).replaceAll('|', '\\|').replaceAll(/\r?\n/gu, ' '); +} + +export function createReleaseSummary(environment) { + const cliExpected = expected(environment.CLI_EXPECTED) || expected(environment.CLI_PUBLISHED); + const cliChecks = [ + ['publish', environment.CLI_PUBLISHED, expected(environment.CLI_PUBLISHED)], + ['native assets', environment.NATIVE_RESULT, succeeded(environment.NATIVE_RESULT)], + ]; + if (enabled(environment.CDN_ENABLED)) { + cliChecks.push( + ['CDN deploy', environment.CDN_DEPLOY_RESULT, succeeded(environment.CDN_DEPLOY_RESULT)], + ['CDN verify', environment.CDN_VERIFY_RESULT, succeeded(environment.CDN_VERIFY_RESULT)], + ); + } + if (enabled(environment.BREW_ENABLED)) { + cliChecks.push(['Homebrew', environment.BREW_RESULT, succeeded(environment.BREW_RESULT)]); + } + + const cliOk = !cliExpected || cliChecks.every(([, , ok]) => ok); + const cliDetails = cliExpected + ? cliChecks.map(([name, result, ok]) => `${name}=${ok ? 'success' : displayResult(result)}`).join(', ') + : 'version unchanged'; + const rows = [ + { + lane: 'Orchestrator', + expectation: 'required', + status: succeeded(environment.RELEASE_RESULT) ? 'PASS' : 'FAIL', + provenance: 'n/a', + details: `job=${displayResult(environment.RELEASE_RESULT)}`, + ok: succeeded(environment.RELEASE_RESULT), + }, + { + lane: 'npm CLI', + expectation: cliExpected ? 'expected' : 'not expected', + status: cliExpected ? (cliOk ? 'PASS' : 'FAIL') : 'SKIP', + provenance: cliExpected ? (succeeded(environment.NATIVE_RESULT) ? 'attested' : 'missing') : 'n/a', + details: cliDetails, + ok: cliOk, + }, + laneRow( + 'Desktop tag', + expected(environment.DESKTOP_EXPECTED), + enabled(environment.DESKTOP_ENABLED), + environment.DESKTOP_RESULT, + ), + laneRow( + 'VS Code', + expected(environment.VSCODE_EXPECTED), + enabled(environment.VSCODE_ENABLED), + environment.VSCODE_RESULT, + true, + ), + ]; + const markdown = [ + '# Release lanes', + '', + '| Lane | Expectation | Status | Provenance | Details |', + '|---|---|---|---|---|', + ...rows.map((row) => `| ${markdownCell(row.lane)} | ${row.expectation} | ${row.status} | ${row.provenance} | ${markdownCell(row.details)} |`), + '', + ].join('\n'); + return { ok: rows.every((row) => row.ok), rows, markdown }; +} + +async function main() { + const summary = createReleaseSummary(process.env); + process.stdout.write(`${summary.markdown}\n`); + if (process.env.GITHUB_STEP_SUMMARY !== undefined && process.env.GITHUB_STEP_SUMMARY !== '') { + await appendFile(process.env.GITHUB_STEP_SUMMARY, summary.markdown); + } + if (!summary.ok) process.exitCode = 1; +} + +if (process.argv[1] === import.meta.filename) { + try { + await main(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/release/render-summary.test.mjs b/scripts/release/render-summary.test.mjs new file mode 100644 index 00000000..57751844 --- /dev/null +++ b/scripts/release/render-summary.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createReleaseSummary } from './render-summary.mjs'; + +const successful = { + RELEASE_RESULT: 'success', + CLI_EXPECTED: 'true', + CLI_PUBLISHED: 'true', + NATIVE_RESULT: 'success', + CDN_ENABLED: 'true', + CDN_DEPLOY_RESULT: 'success', + CDN_VERIFY_RESULT: 'success', + BREW_ENABLED: 'true', + BREW_RESULT: 'success', + DESKTOP_EXPECTED: 'true', + DESKTOP_ENABLED: 'true', + DESKTOP_RESULT: 'success', + VSCODE_EXPECTED: 'true', + VSCODE_ENABLED: 'true', + VSCODE_RESULT: 'success', +}; + +void test('passes when every expected release lane succeeds', () => { + const summary = createReleaseSummary(successful); + assert.equal(summary.ok, true); + assert.match(summary.markdown, /\| npm CLI \| expected \| PASS \| attested \|/u); + assert.match(summary.markdown, /\| Desktop tag \| expected \| PASS \| n\/a \|/u); + assert.match(summary.markdown, /\| VS Code \| expected \| PASS \| attested \|/u); +}); + +void test('fails when an expected CDN deployment fails', () => { + const summary = createReleaseSummary({ ...successful, CDN_DEPLOY_RESULT: 'failure' }); + assert.equal(summary.ok, false); + assert.match(summary.markdown, /CDN deploy=failure/u); +}); + +void test('accepts an explicit lane disable and shows it', () => { + const summary = createReleaseSummary({ + ...successful, + VSCODE_ENABLED: 'false', + VSCODE_RESULT: 'skipped', + }); + assert.equal(summary.ok, true); + assert.match(summary.markdown, /\| VS Code \| disabled \| SKIP \| n\/a \|/u); +});