Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions .github/workflows/deploy-reader.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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'
Expand All @@ -62,15 +68,15 @@ 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 }}
VITE_MDBASE_FEEDBACK_URL: ${{ vars.MDBASE_FEEDBACK_URL }}
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)
Expand Down
88 changes: 88 additions & 0 deletions .github/workflows/sdk-repin.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
112 changes: 112 additions & 0 deletions scripts/sdk-repin.mjs
Original file line number Diff line number Diff line change
@@ -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();
Loading