Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ jobs:
verify:
name: Verify commercial application
runs-on: ubuntu-24.04
timeout-minutes: 25
timeout-minutes: 45
env:
REPO_DOCS_CHANNEL: ${{ github.event_name == 'pull_request' && 'preview' || 'stable' }}
steps:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/open-source.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ jobs:
verify:
name: Verify open-source application
runs-on: ubuntu-24.04
timeout-minutes: 25
timeout-minutes: 45
env:
REPO_DOCS_CHANNEL: ${{ github.event_name == 'pull_request' && 'preview' || 'stable' }}
steps:
Expand Down
67 changes: 67 additions & 0 deletions .github/workflows/recover-pages-0.4.0.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: Recover the verified 0.4.0 Pages artifact

on:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: docs-open-source-stable
cancel-in-progress: false

jobs:
recover:
if: ${{ github.repository == 'weappjs/weapp.dev' && github.ref == 'refs/heads/main' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
environment: docs-open-source-stable
permissions:
contents: write
actions: read
pages: write
id-token: write
env:
LEFTHOOK: '0'
REPO_DOCS_CONCURRENCY_GROUP: docs-open-source-stable
steps:
- name: Checkout recovery controller
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Checkout the frozen release adapter
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: 0b26eea7da1b67f66d31b639e5bd11087b5295d6
path: .recovery/source
persist-credentials: false
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
package_json_file: .recovery/source/package.json
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24.22.1
- name: Install frozen adapter dependencies without build scripts
working-directory: .recovery/source
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Recover only the verified original tarball
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
GITHUB_TOKEN: ${{ github.token }}
with:
github-token: ${{ github.token }}
script: |
const { spawnSync } = require('node:child_process')
const result = spawnSync('node', ['scripts/recover-pages-0.4.0.mjs'], {
stdio: 'inherit', env: process.env,
})
if (result.error || result.status !== 0) throw new Error('Pages artifact recovery stopped; inspect sanitized diagnostics.')
- name: Preserve recovery evidence
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: pages-recovery-${{ github.run_id }}-${{ github.run_attempt }}
path: .cache/pages-recovery
include-hidden-files: true
if-no-files-found: ignore
retention-days: 30
15 changes: 15 additions & 0 deletions patches/@icebreakers__monorepo@6.0.1.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
diff --git a/dist/commands-D1KtdxnV.mjs b/dist/commands-D1KtdxnV.mjs
index 79b651dba83031ed6f414bbc9d7afe77d49ab0a1..236e2472bfe1fe1f229904c889ee3942e5c49390 100644
--- a/dist/commands-D1KtdxnV.mjs
+++ b/dist/commands-D1KtdxnV.mjs
@@ -5599,7 +5599,9 @@ function readAutomationState(request, key) {
}
function transient(error) {
if (!error || typeof error !== "object" || !("status" in error) || typeof error.status !== "number") return false;
- return error.status === 0 || error.status === 429 || error.status >= 500;
+ // Contents API can conflict when another file advances the shared branch.
+ // writeState only retries after rereading the unchanged target revision.
+ return error.status === 0 || error.status === 409 || error.status === 429 || error.status >= 500;
}
async function writeState(request, endpoint, state, revision, options = {}) {
if (!revision) try {
5 changes: 3 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ overrides:
# Remove when oxfmt no longer pins the vulnerable tinypool 2.1.0.
tinypool@2.1.0: 2.1.2
patchedDependencies:
# Remove when upstream retries branch conflicts after verifying the target state is unchanged.
'@icebreakers/monorepo@6.0.1': patches/@icebreakers__monorepo@6.0.1.patch
# Remove after upstream includes Astro templates in source scanning and HMR.
'@weapp-tailwindcss/source-scan@0.1.2': patches/@weapp-tailwindcss__source-scan@0.1.2.patch
minimumReleaseAgeExclude:
Expand Down
26 changes: 21 additions & 5 deletions scripts/docs-deployment/cloudflare.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { run, verifyArtifact, writeResult } from './context.mjs'
import { pause, run, verifyArtifact, writeResult } from './context.mjs'

export const deploymentMessage = identity => `repoctl-docs:${identity.deploymentId}`

Expand Down Expand Up @@ -99,6 +99,25 @@ async function workerUrl(context, client) {
return `https://${context.worker}.${result.subdomain}.workers.dev`
}

async function confirmActiveVersion(client, worker, versionId, expected, options) {
const limit = options.pollLimit ?? 30
for (let attempt = 0; attempt < limit; attempt++) {
const active = await activeVersion(client, worker)
if (active?.id === versionId) {
if (active.message !== expected) {
throw new Error('The uploaded Cloudflare version has an unexpected deployment identity.')
}
return active
}
// The deploy command can finish before the read API reflects activation.
// Only observe the known upload; a delayed read must never trigger a write.
if (attempt + 1 < limit) {
await (options.sleep ?? pause)(2000)
}
}
throw new Error('Cloudflare did not confirm this exact version at 100% traffic; retry the same deployment.')
}

export async function deployCloudflare(context, options = {}) {
if (!context.worker) {
throw new Error('This site channel does not use Cloudflare.')
Expand All @@ -111,10 +130,7 @@ export async function deployCloudflare(context, options = {}) {
if (active?.message !== expected) {
const output = await invokeWrangler(context, ['deploy'], options.run ?? run)
const uploaded = readWranglerOutput(output, context.worker, 'deploy')
active = await activeVersion(client, context.worker)
if (active?.id !== uploaded.version_id || active.message !== expected) {
throw new Error('Cloudflare did not confirm this exact version at 100% traffic; retry the same deployment.')
}
active = await confirmActiveVersion(client, context.worker, uploaded.version_id, expected, options)
}
return writeResult(context, { versionId: active.id, url: await workerUrl(context, client) })
}
Expand Down
81 changes: 75 additions & 6 deletions scripts/docs-deployment/deployment.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest'
import { activeVersion, cloudflareClient, deployCloudflare, deploymentMessage, readWranglerOutput, uploadPrPreview, verifyWorkerTarget } from './cloudflare.mjs'
import { assertIdentity, managedContext, prContext, writeBuildIdentity } from './context.mjs'
import { sanitizeMessage, writeDiagnostic } from './diagnostics.mjs'
import { pagesReceiptStore } from './github.mjs'
import { githubClient, pagesReceiptStore } from './github.mjs'
import { deployPages, waitForPages } from './pages.mjs'

const sha = 'a'.repeat(40)
Expand Down Expand Up @@ -140,20 +140,72 @@ describe('Cloudflare provider', () => {
? { deployments: [{ created_on: '2026-10-23', versions: [{ version_id: version, percentage: 100 }] }] }
: { id: version, annotations: { 'workers/message': deploymentMessage(context.identity) } })
const execute = vi.fn()
expect(await deployCloudflare(context, { client, run: execute })).toEqual({ versionId: version, url: 'https://weapp.dev' })
const sleep = vi.fn()
expect(await deployCloudflare(context, { client, run: execute, sleep })).toEqual({ versionId: version, url: 'https://weapp.dev' })
expect(execute).not.toHaveBeenCalled()
expect(sleep).not.toHaveBeenCalled()
expect(JSON.parse(await readFile(context.env.REPO_DOCS_RESULT_FILE, 'utf8')).versionId).toBe(version)
}))

it('does not record an uploaded version until the API confirms it active', async () => fixture('web', async (context) => {
const client = async endpoint => endpoint.endsWith('/deployments')
const client = vi.fn(async endpoint => endpoint.endsWith('/deployments')
? { deployments: [{ created_on: '2026-10-23', versions: [{ version_id: oldVersion, percentage: 100 }] }] }
: { id: oldVersion, annotations: { 'workers/message': 'old' } }
: { id: oldVersion, annotations: { 'workers/message': 'old' } })
const execute = vi.fn(async (_command, args, options) => {
expect(args).toContain('deploy')
await writeFile(options.env.WRANGLER_OUTPUT_FILE_PATH, JSON.stringify({ type: 'deploy', worker_name: context.worker, version_id: version }))
})
await expect(deployCloudflare(context, { client, run: execute })).rejects.toThrow(/exact version/)
const sleep = vi.fn()
await expect(deployCloudflare(context, { client, run: execute, pollLimit: 3, sleep })).rejects.toThrow(/exact version/)
expect(execute).toHaveBeenCalledOnce()
expect(client.mock.calls.filter(([endpoint]) => endpoint.endsWith('/deployments'))).toHaveLength(4)
expect(sleep.mock.calls).toEqual([[2000], [2000]])
await expect(readFile(context.env.REPO_DOCS_RESULT_FILE)).rejects.toMatchObject({ code: 'ENOENT' })
}))

it('waits for the uploaded version to become active without deploying twice', async () => fixture('web', async (context) => {
let reads = 0
const client = vi.fn(async (endpoint) => {
if (endpoint.endsWith('/deployments')) {
const id = reads++ < 2 ? oldVersion : version
return { deployments: [{ created_on: '2026-10-23', versions: [{ version_id: id, percentage: 100 }] }] }
}
const id = endpoint.split('/').at(-1)
return { id, annotations: { 'workers/message': id === version ? deploymentMessage(context.identity) : 'old' } }
})
const execute = vi.fn(async (_command, _args, options) => {
await writeFile(options.env.WRANGLER_OUTPUT_FILE_PATH, JSON.stringify({ type: 'deploy', worker_name: context.worker, version_id: version }))
})
const sleep = vi.fn()
expect(await deployCloudflare(context, { client, run: execute, pollLimit: 3, sleep })).toEqual({ versionId: version, url: 'https://weapp.dev' })
expect(execute).toHaveBeenCalledOnce()
expect(reads).toBe(3)
expect(sleep).toHaveBeenCalledExactlyOnceWith(2000)
expect(JSON.parse(await readFile(context.env.REPO_DOCS_RESULT_FILE, 'utf8')).versionId).toBe(version)
}))

it.each(['split traffic', 'invalid response', 'wrong identity'])('stops confirmation immediately on %s', async failure => fixture('web', async (context) => {
let uploaded = false
const client = vi.fn(async (endpoint) => {
if (endpoint.endsWith('/deployments')) {
if (uploaded && failure === 'invalid response') {
return { deployments: null }
}
const id = uploaded ? version : oldVersion
return { deployments: [{ created_on: '2026-10-23', versions: [{ version_id: id, percentage: uploaded && failure === 'split traffic' ? 50 : 100 }] }] }
}
const id = endpoint.split('/').at(-1)
return { id, annotations: { 'workers/message': 'unexpected identity' } }
})
const execute = vi.fn(async (_command, _args, options) => {
uploaded = true
await writeFile(options.env.WRANGLER_OUTPUT_FILE_PATH, JSON.stringify({ type: 'deploy', worker_name: context.worker, version_id: version }))
})
const sleep = vi.fn()
const message = { 'split traffic': /100%/, 'invalid response': /Invalid Cloudflare/, 'wrong identity': /unexpected deployment identity/ }[failure]
await expect(deployCloudflare(context, { client, run: execute, pollLimit: 3, sleep })).rejects.toThrow(message)
expect(execute).toHaveBeenCalledOnce()
expect(sleep).not.toHaveBeenCalled()
await expect(readFile(context.env.REPO_DOCS_RESULT_FILE)).rejects.toMatchObject({ code: 'ENOENT' })
}))

Expand Down Expand Up @@ -225,7 +277,7 @@ describe('Pages provider', () => {
const client = vi.fn(async (endpoint, options) => {
if (options?.method === 'POST') {
expect(store.current().phase).toBe('submitting')
expect(options.body).toEqual({ artifact_id: 42, environment: 'docs-open-source-stable', pages_build_version: identity, oidc_token: 'secret-token' })
expect(options.body).toEqual({ artifact_id: 42, environment: 'docs-open-source-stable', pages_build_version: context.identity.sourceSha, oidc_token: 'secret-token' })
return { id: 'pages-123', page_url: 'https://weapp.js.org/' }
}
expect(endpoint).toBe('pages/deployments/pages-123')
Expand Down Expand Up @@ -371,6 +423,23 @@ describe('Pages provider', () => {
})

describe('deployment diagnostics', () => {
it('reports a safe provider explanation and request ID without leaking credentials', async () => {
const client = githubClient({ GITHUB_REPOSITORY: 'weappjs/weapp.dev', GITHUB_TOKEN: 'private-token' }, async () => Response.json({
message: 'Missing build private-token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature',
debug: 'must-not-be-logged',
}, { status: 404, headers: { 'x-github-request-id': 'AB12:CD34' } }))
await expect(client('pages/deployments', { method: 'POST', body: { oidc_token: 'secret' } })).rejects.toMatchObject({
status: 404,
method: 'POST',
message: 'GitHub POST pages/deployments failed (HTTP 404). Request ID: AB12:CD34. Missing build [REDACTED] [REDACTED JWT]',
})
})

it('preserves the provider status when its error body is not JSON', async () => {
const client = githubClient({ GITHUB_REPOSITORY: 'weappjs/weapp.dev', GITHUB_TOKEN: 'private-token' }, async () => new Response('<html>gateway</html>', { status: 502 }))
await expect(client('pages/deployments')).rejects.toMatchObject({ status: 502, method: 'GET', message: 'GitHub GET pages/deployments failed (HTTP 502).' })
})

it('redacts environment credentials, bearer tokens and runtime JWTs', () => {
expect(sanitizeMessage('error cf-secret ghp_abc123 Bearer unknown eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature', { CLOUDFLARE_API_TOKEN: 'cf-secret' }))
.toBe('error [REDACTED] [REDACTED TOKEN] Bearer [REDACTED] [REDACTED JWT]')
Expand Down
8 changes: 7 additions & 1 deletion scripts/docs-deployment/github.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Buffer } from 'node:buffer'
import { sanitizeMessage } from './diagnostics.mjs'

const stateBranch = 'repoctl-release-state'

Expand All @@ -23,7 +24,12 @@ export function githubClient(env, request = fetch) {
return undefined
}
if (!response.ok) {
const error = new Error(`GitHub ${options.method || 'GET'} ${endpoint.split('?')[0]} failed (HTTP ${response.status}).`)
// Keep the provider's selected explanation without persisting request
// payloads, authorization headers, OIDC tokens or arbitrary response data.
const details = await response.json().catch(() => undefined)
const message = typeof details?.message === 'string' ? sanitizeMessage(details.message, env).slice(0, 1000) : ''
const requestId = (response.headers.get('x-github-request-id') || '').replace(/[^a-z\d:-]/gi, '').slice(0, 100)
const error = new Error(`GitHub ${options.method || 'GET'} ${endpoint.split('?')[0]} failed (HTTP ${response.status}).${requestId ? ` Request ID: ${requestId}.` : ''}${message ? ` ${message}` : ''}`)
error.status = response.status
error.method = options.method || 'GET'
throw error
Expand Down
Loading
Loading