diff --git a/.github/workflows/deploy-reader.yml b/.github/workflows/deploy-reader.yml index a70e1c44..35541bcb 100644 --- a/.github/workflows/deploy-reader.yml +++ b/.github/workflows/deploy-reader.yml @@ -1,7 +1,13 @@ name: Deploy Reader -# Lab deploys from CI on every push to main. Staging and production are explicit. +# Dependency merges use the existing production publisher; manual paths remain. on: + push: + branches: [main] + paths: + - "**/package.json" + - package.json + - pnpm-lock.yaml workflow_dispatch: inputs: target: @@ -27,18 +33,18 @@ jobs: deploy: if: inputs.source_sha == '' - name: Deploy Reader to ${{ inputs.target }} + name: Deploy Reader to ${{ inputs.target || 'production' }} needs: ci runs-on: ubuntu-24.04 timeout-minutes: 15 concurrency: - group: deploy-reader-${{ inputs.target }} + group: deploy-reader-${{ inputs.target || 'production' }} cancel-in-progress: false environment: - name: reader-${{ inputs.target }} - url: ${{ inputs.target == 'production' && 'https://reader.mdbase.dev/' || 'https://staging.mdbase-reader.pages.dev/' }} + name: reader-${{ inputs.target || 'production' }} + url: ${{ inputs.target != 'staging' && 'https://reader.mdbase.dev/' || 'https://staging.mdbase-reader.pages.dev/' }} env: - MDBASE_ENV: ${{ inputs.target }} + MDBASE_ENV: ${{ inputs.target || 'production' }} steps: - name: Require main if: github.ref != 'refs/heads/main' @@ -62,7 +68,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile # deploy-pages.mjs refuses a dirty tree for staging and production. - - run: pnpm ${{ inputs.target == 'production' && 'deploy:prod' || 'deploy:dev' }} + - run: pnpm ${{ inputs.target != 'staging' && 'deploy:prod' || 'deploy:dev' }} env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -70,7 +76,7 @@ jobs: VITE_MDBASE_FEEDBACK_TURNSTILE_SITE_KEY: ${{ vars.MDBASE_FEEDBACK_TURNSTILE_SITE_KEY }} - name: Verify deployed manifest env: - ORIGIN: ${{ inputs.target == 'production' && 'https://reader.mdbase.dev' || 'https://staging.mdbase-reader.pages.dev' }} + ORIGIN: ${{ inputs.target != 'staging' && 'https://reader.mdbase.dev' || 'https://staging.mdbase-reader.pages.dev' }} run: | for attempt in 1 2 3 4 5 6 7 8 9 10 11 12; do homepage=$(curl -fsS "$ORIGIN/.well-known/mdbase-app.json" | jq -r .homepage || true) diff --git a/.github/workflows/sdk-repin.yml b/.github/workflows/sdk-repin.yml new file mode 100644 index 00000000..e57c3c85 --- /dev/null +++ b/.github/workflows/sdk-repin.yml @@ -0,0 +1,88 @@ +name: Repin production Connect packages + +on: + workflow_dispatch: + inputs: + connect_release: + description: Production-verified Connect tag (v0.1.0-beta.N) + required: true + type: string + +permissions: + contents: read + +concurrency: + group: sdk-repin + cancel-in-progress: false + +jobs: + repin: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Mint the release automation App token + id: app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_AUTOMATION_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_AUTOMATION_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + - name: Mint read-only access to the production record + id: ops + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_AUTOMATION_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_AUTOMATION_APP_PRIVATE_KEY }} + owner: mdbase-dev + repositories: mdbase-cloud-ops + permission-contents: read + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + token: ${{ steps.app.outputs.token }} + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + package-manager-cache: false + - name: Validate production identity and plan exact pins + id: pins + env: + GH_TOKEN: ${{ steps.ops.outputs.token }} + CONNECT_RELEASE: ${{ inputs.connect_release }} + run: node scripts/sdk-repin.mjs + - name: Refresh the existing lockfile + if: steps.pins.outputs.changed == 'true' + run: pnpm install --lockfile-only --ignore-scripts + - name: Open or update the single release PR + if: steps.pins.outputs.changed == 'true' + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + APP_SLUG: ${{ steps.app.outputs.app-slug }} + CONNECT_RELEASE: ${{ inputs.connect_release }} + CONNECT_COMMIT: ${{ steps.pins.outputs.commit }} + run: | + if git diff --quiet; then exit 0; fi + branch=automation/sdk-repin + previous=$(git ls-remote origin "refs/heads/$branch" | awk '{print $1}') + git switch --create "$branch" + git config user.name "${APP_SLUG}[bot]" + git config user.email "${APP_SLUG}[bot]@users.noreply.github.com" + git ls-files -z -- '*package.json' pnpm-lock.yaml | xargs -0 git add -- + git diff --cached --quiet && exit 0 + git commit -m "Pin production Connect packages to $CONNECT_RELEASE" + git push --force-with-lease="refs/heads/$branch:$previous" origin "HEAD:refs/heads/$branch" + title="Pin production Connect packages to $CONNECT_RELEASE" + body=$(printf 'Connect: %s, commit %s.\n\nTrain run: https://github.com/%s/actions/runs/%s\n\nExact npm pins and pnpm lockfile only. App CI and normal branch protections gate auto-merge. Reader web uses the existing production publisher on dependency merges; extension version/tag/store handoff remains separate.\n' "$CONNECT_RELEASE" "$CONNECT_COMMIT" "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID") + pull=$(gh pr list --head "$branch" --base main --state open --json number --jq '.[0].number // empty') + if [ -n "$pull" ]; then + gh pr edit "$pull" --title "$title" --body "$body" + else + gh pr create --head "$branch" --base main --title "$title" --body "$body" + pull=$(gh pr list --head "$branch" --base main --state open --json number --jq '.[0].number') + fi + gh pr merge "$pull" --auto --squash diff --git a/package.json b/package.json index d07f9f79..d5cca35e 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "test:workspaces": "pnpm -r --if-present test", "test:architecture": "node --test scripts/lib/architecture-check.test.mjs", "test:publisher": "node --test scripts/tests/*.test.mjs", + "test:sdk-repin": "node --test scripts/tests/sdk-repin.test.mjs", "test:publisher:evidence": "node --test scripts/tests/reader-evidence.test.mjs", "test:publisher:review": "node --test scripts/review-tests/*.test.mjs", "test:publisher:offline": "node --test scripts/tests/reader-offline.test.mjs", diff --git a/scripts/sdk-repin.mjs b/scripts/sdk-repin.mjs new file mode 100644 index 00000000..c7f1e2f0 --- /dev/null +++ b/scripts/sdk-repin.mjs @@ -0,0 +1,112 @@ +import { execFileSync } from "node:child_process"; +import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +export function productionIdentity(record, release, tag) { + if (!/^v0\.1\.0-beta\.[1-9][0-9]*$/.test(release)) { + throw new Error("connect_release must be v0.1.0-beta.N"); + } + const recordedRelease = record.match(/^MDBASE_CONNECT_RELEASE=(.+)$/m)?.[1]; + const commit = record.match(/^MDBASE_CONNECT_COMMIT=([0-9a-f]{40})$/m)?.[1]; + if (recordedRelease !== release || !commit || tag !== commit) { + throw new Error("Connect tag does not match ops main's production record"); + } + return commit; +} + +export function repinPlan(manifests, version) { + const packages = new Set(["@mdbase-dev/sdk"]); + const changes = []; + for (const { path, text } of manifests) { + const manifest = JSON.parse(text); + let changed = false; + for (const section of [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + ]) { + for (const [name, pin] of Object.entries(manifest[section] ?? {})) { + if (!name.startsWith("@mdbase-dev/")) continue; + if (name !== "@mdbase-dev/sdk" && /^(file:|workspace:|link:)/.test(pin)) continue; + if (/^@mdbase-dev\/sdk-/.test(name)) continue; + if (/^(npm:|git|https?:)/.test(pin)) { + throw new Error(`Unsupported runtime pin: ${path}: ${name}`); + } + packages.add(name); + if (pin !== version) { + manifest[section][name] = version; + changed = true; + } + } + } + if (changed) { + changes.push({ path, text: JSON.stringify(manifest, null, 2) + "\n" }); + } + } + return { packages: [...packages].sort(), changes }; +} + +function execute(...args) { + return execFileSync(args[0], args.slice(1), { + encoding: "utf8", + timeout: 60_000, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function main() { + const release = process.env.CONNECT_RELEASE ?? ""; + if (!/^v0\.1\.0-beta\.[1-9][0-9]*$/.test(release)) { + throw new Error("connect_release must be v0.1.0-beta.N"); + } + const record = JSON.parse( + execute("gh", "api", "repos/mdbase-dev/mdbase-cloud-ops/contents/render/release.env?ref=main"), + ); + const tag = execute( + "git", + "ls-remote", + "https://github.com/mdbase-dev/mdbase-connect.git", + `refs/tags/${release}^{}`, + ).split(/\s/)[0]; + const commit = productionIdentity( + Buffer.from(record.content, "base64").toString("utf8"), + release, + tag, + ); + const manifests = execute("git", "ls-files", "-z", "*package.json") + .split("\0") + .filter(Boolean) + .map((path) => ({ path, text: readFileSync(path, "utf8") })); + const plan = repinPlan(manifests, release.slice(1)); + for (const name of plan.packages) { + let published; + try { + published = JSON.parse( + execute( + "npm", + "view", + `${name}@${release.slice(1)}`, + "version", + "--json", + "--registry=https://registry.npmjs.org", + "--fetch-retries=0", + ), + ); + } catch { + throw new Error( + `${name}@${release.slice(1)} is unavailable; no partial repin. SDK npm publication requires Connect PR 1076 and a published production release.`, + ); + } + if (published !== release.slice(1)) { + throw new Error(`Registry identity mismatch for ${name}`); + } + } + for (const change of plan.changes) writeFileSync(change.path, change.text); + appendFileSync( + process.env.GITHUB_OUTPUT, + `changed=${plan.changes.length > 0}\ncommit=${commit}\n`, + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main(); diff --git a/scripts/tests/sdk-repin.test.mjs b/scripts/tests/sdk-repin.test.mjs new file mode 100644 index 00000000..d11c0385 --- /dev/null +++ b/scripts/tests/sdk-repin.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { productionIdentity, repinPlan } from "../sdk-repin.mjs"; + +const commit = "a".repeat(40); +const release = "v0.1.0-beta.148"; +const version = release.slice(1); +const record = `MDBASE_CONNECT_RELEASE=${release}\nMDBASE_CONNECT_COMMIT=${commit}\n`; + +test("only the exact annotated production identity is accepted", () => { + assert.equal(productionIdentity(record, release, commit), commit); + for (const [input, tag] of [ + ["latest", commit], + ["v0.1.0-beta.147", commit], + [release, "b".repeat(40)], + [release, ""], + ]) { + assert.throws(() => productionIdentity(record, input, tag)); + } +}); + +test("all npm pins and production SDK advance together, aliases/originals stay", () => { + const plan = repinPlan( + [ + { + path: "package.json", + text: JSON.stringify({ + dependencies: { + "@mdbase-dev/ui": "^0.1.0-beta.147", + "@mdbase-dev/sdk": "file:vendor/original.tgz", + "@mdbase-dev/sdk-staging": "file:vendor/staging.tgz", + "@mdbase-dev/sdk-lab": "file:vendor/lab.tgz", + "@mdbase-dev/sdk-legacy-729b5391": "file:vendor/legacy.tgz", + "@mdbase-dev/connect-protocol": "file:vendor/protocol.tgz", + "@other/package": "1.0.0", + }, + devDependencies: { "@mdbase-dev/connect-dev": "0.1.0-beta.125" }, + optionalDependencies: { "@mdbase-dev/i18n": "~0.1.0-beta.147" }, + }), + }, + ], + version, + ); + const changed = JSON.parse(plan.changes[0].text); + assert.equal(changed.dependencies["@mdbase-dev/ui"], version); + assert.equal(changed.dependencies["@mdbase-dev/sdk"], version); + assert.equal(changed.devDependencies["@mdbase-dev/connect-dev"], version); + assert.equal(changed.optionalDependencies["@mdbase-dev/i18n"], version); + assert.equal(changed.dependencies["@mdbase-dev/sdk-staging"], "file:vendor/staging.tgz"); + assert.equal(changed.dependencies["@mdbase-dev/connect-protocol"], "file:vendor/protocol.tgz"); + assert.equal(changed.dependencies["@other/package"], "1.0.0"); + assert.deepEqual(plan.packages, [ + "@mdbase-dev/connect-dev", + "@mdbase-dev/i18n", + "@mdbase-dev/sdk", + "@mdbase-dev/ui", + ]); +}); + +test("matching exact pins do not rewrite formatting or create a PR", () => { + const plan = repinPlan( + [ + { + path: "package.json", + text: JSON.stringify({ dependencies: { "@mdbase-dev/sdk": version } }), + }, + ], + version, + ); + assert.deepEqual(plan.changes, []); +}); + +test("unexpected remote pin schemes refuse rather than silently migrate", () => { + assert.throws(() => + repinPlan( + [ + { + path: "package.json", + text: JSON.stringify({ + dependencies: { "@mdbase-dev/ui": "https://example.test/ui.tgz" }, + }), + }, + ], + version, + ), + ); +}); + +test("missing SDK refuses atomically; successful repin and repeat no-op use exact versions", () => { + const directory = mkdtempSync(join(tmpdir(), "reader-sdk-repin-")); + try { + const binaries = join(directory, "bin"); + mkdirSync(binaries); + const original = JSON.stringify({ + dependencies: { + "@mdbase-dev/ui": "0.1.0-beta.147", + "@mdbase-dev/sdk": "file:original-sdk.tgz", + }, + }); + writeFileSync(join(directory, "package.json"), original); + const fake = `#!${process.execPath} +const command = process.argv[1].split('/').pop(); +if (command === 'gh') console.log(JSON.stringify({content: Buffer.from(${JSON.stringify(record)}).toString('base64')})); +else if (command === 'git') console.log(process.argv[2] === 'ls-remote' ? '${commit}\\trefs/tags/${release}^{}' : 'package.json\\0'); +else if (process.argv[3].startsWith('@mdbase-dev/sdk@') && process.env.MISSING_SDK === 'true') process.exit(1); +else console.log(JSON.stringify('${version}')); +`; + for (const name of ["gh", "git", "npm"]) { + writeFileSync(join(binaries, name), fake, { mode: 0o755 }); + } + const output = join(directory, "output"); + const invoke = (missing) => + execFileSync( + process.execPath, + [fileURLToPath(new URL("../sdk-repin.mjs", import.meta.url))], + { + cwd: directory, + env: { + ...process.env, + CONNECT_RELEASE: release, + GITHUB_OUTPUT: output, + MISSING_SDK: String(missing), + PATH: `${binaries}:${process.env.PATH}`, + }, + stdio: "pipe", + }, + ); + assert.throws(() => invoke(true), /no partial repin/); + assert.equal(readFileSync(join(directory, "package.json"), "utf8"), original); + assert.equal(existsSync(output), false); + invoke(false); + assert.equal( + JSON.parse(readFileSync(join(directory, "package.json"), "utf8")).dependencies[ + "@mdbase-dev/sdk" + ], + version, + ); + assert.match(readFileSync(output, "utf8"), /changed=true/); + writeFileSync(output, ""); + invoke(false); + assert.match(readFileSync(output, "utf8"), /changed=false/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +});