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
32 changes: 18 additions & 14 deletions .github/workflows/deploy-cloudflare.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,25 +163,27 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
fetch-depth: 2
- name: Detect Harness changes
id: changes
run: |
if [ "${{ github.event_name }}" = workflow_dispatch ] || ! git diff --quiet HEAD^ HEAD -- workers/skill-harness pnpm-workspace.yaml pnpm-lock.yaml .github/workflows/deploy-cloudflare.yml; then
echo "deploy=true" >> "$GITHUB_OUTPUT"
else
echo "deploy=false" >> "$GITHUB_OUTPUT"
echo 'The Harness Worker did not change, so it keeps its current version.' >> "$GITHUB_STEP_SUMMARY"
fi
# The live version's commit can be many merges back.
fetch-depth: 0
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
if: steps.changes.outputs.deploy == 'true'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: steps.changes.outputs.deploy == 'true'
with:
node-version: '24'
cache: pnpm
- run: pnpm install --frozen-lockfile --ignore-scripts
if: steps.changes.outputs.deploy == 'true'
# Compares the commit the live version was built from, not HEAD^. A merge
# that cancels an earlier run would otherwise hide that run's Harness change.
- name: Detect Harness changes
id: changes
run: |
if [ "${{ github.event_name }}" = workflow_dispatch ]; then
echo "deploy=true" >> "$GITHUB_OUTPUT"
else
pnpm exec tsx scripts/harness-deploy-plan.ts
fi
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- name: Check the Harness Worker
if: steps.changes.outputs.deploy == 'true'
run: |
Expand All @@ -190,7 +192,9 @@ jobs:
pnpm --filter @skilld/skill-harness-worker test:run
- name: Deploy the Harness proof
if: steps.changes.outputs.deploy == 'true'
run: pnpm --filter @skilld/skill-harness-worker deploy:proof
# The tag names the commit, so the next run diffs from what is live.
run: pnpm --filter @skilld/skill-harness-worker deploy:proof --tag "${COMMIT:0:12}" --message "$COMMIT"
env:
COMMIT: ${{ github.event.workflow_run.head_sha || github.sha }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
4 changes: 3 additions & 1 deletion docs/runbooks/skill-harness-proof.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ The proof produces a file bundle. [GitHub](https://github.com) tag handling and
## Deploy the proof

A merge to main deploys the `skilld-harness-proof` Worker and its container image after the site deploy.
It deploys only when the merge changed `workers/skill-harness/`, `pnpm-workspace.yaml`, `pnpm-lock.yaml`, or the deploy workflow.
Each deploy tags the version with its commit. A main deploy compares that commit with HEAD.
It deploys when `workers/skill-harness/`, `pnpm-workspace.yaml`, `pnpm-lock.yaml`, or the deploy workflow changed since then.
So a merge that cancels an earlier run still ships that run's Worker change. Container instances roll out at once.
A new version restarts containers, so an unchanged merge keeps the running version.

To deploy a branch by hand, dispatch the workflow with `target=harness-proof`.
Expand Down
37 changes: 37 additions & 0 deletions scripts/harness-deploy-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Decides whether main's deploy ships the skill-harness Worker, and writes
// `deploy=true|false` to GITHUB_OUTPUT. Needs full git history and Cloudflare credentials.
import { execFileSync } from 'node:child_process'
import { appendFileSync } from 'node:fs'
import { resolve } from 'node:path'
import process from 'node:process'
import { harnessDeployDecision, liveVersionId, versionCommit } from './lib/harness-deploy'

const workerDirectory = resolve(process.cwd(), 'workers/skill-harness')
const wrangler = resolve(workerDirectory, 'node_modules/.bin/wrangler')

function wranglerJson(args: string[]): unknown {
return JSON.parse(execFileSync(wrangler, [...args, '--config', 'wrangler.jsonc', '--json'], { cwd: workerDirectory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }))
}

/** `git diff --name-only`, or undefined when the commit is not in this history. */
function changedSince(commit: string): string[] | undefined {
try {
execFileSync('git', ['cat-file', '-e', `${commit}^{commit}`], { stdio: 'ignore' })
}
catch {
// A missing commit is an expected answer: the live version came from a branch.
return undefined
}
return execFileSync('git', ['diff', '--name-only', commit, 'HEAD'], { encoding: 'utf8' }).split('\n').filter(Boolean)
}

const versionId = liveVersionId(wranglerJson(['deployments', 'list']))
const liveCommit = versionId === undefined ? undefined : versionCommit(wranglerJson(['versions', 'view', versionId]))
const decision = harnessDeployDecision({ liveCommit, changedPaths: liveCommit === undefined ? [] : changedSince(liveCommit) })

const message = decision._tag === 'Deploy' ? `Deploying the Harness Worker: ${decision.reason}` : `The Harness Worker matches ${liveCommit}, so it keeps its current version.`
console.log(message)
if (process.env.GITHUB_OUTPUT)
appendFileSync(process.env.GITHUB_OUTPUT, `deploy=${decision._tag === 'Deploy'}\n`)
if (process.env.GITHUB_STEP_SUMMARY)
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${message}\n`)
43 changes: 43 additions & 0 deletions scripts/lib/harness-deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Whether main's deploy must ship the skill-harness Worker. It compares the
* commit the live version was built from, not HEAD^: when a later merge
* cancels a run, HEAD^ to HEAD hides the change that run would have shipped.
*/

/** The paths the Harness Worker and its container image build from. */
export const HARNESS_PATHS = ['workers/skill-harness', 'pnpm-workspace.yaml', 'pnpm-lock.yaml', '.github/workflows/deploy-cloudflare.yml'] as const

export type HarnessDeployDecision
= | { readonly _tag: 'Deploy', readonly reason: string }
| { readonly _tag: 'Keep' }

/**
* `changedPaths` is `git diff --name-only LIVE HEAD`, or undefined when the
* live commit is not in this history.
*/
export function harnessDeployDecision(input: { liveCommit: string | undefined, changedPaths: ReadonlyArray<string> | undefined }): HarnessDeployDecision {
if (input.liveCommit === undefined)
return { _tag: 'Deploy', reason: 'The live Harness version names no commit.' }
if (input.changedPaths === undefined)
return { _tag: 'Deploy', reason: `Commit ${input.liveCommit} is not in this history.` }
const changed = input.changedPaths.find(path => HARNESS_PATHS.some(prefix => path === prefix || path.startsWith(`${prefix}/`)))
return changed === undefined ? { _tag: 'Keep' } : { _tag: 'Deploy', reason: `${changed} changed since ${input.liveCommit}.` }
}

interface Deployment { readonly versions?: ReadonlyArray<{ readonly version_id?: string, readonly percentage?: number }> }

/** The version carrying most traffic in the newest deployment of `wrangler deployments list --json`. */
export function liveVersionId(deployments: unknown): string | undefined {
if (!Array.isArray(deployments))
return undefined
const newest = deployments.at(-1) as Deployment | undefined
return [...newest?.versions ?? []].sort((a, b) => (b.percentage ?? 0) - (a.percentage ?? 0))[0]?.version_id
}

const COMMIT = /^[0-9a-f]{7,40}$/

/** The commit a version was deployed from, read from its `--tag`. */
export function versionCommit(version: unknown): string | undefined {
const tag = (version as { annotations?: Record<string, unknown> } | undefined)?.annotations?.['workers/tag']
return typeof tag === 'string' && COMMIT.test(tag) ? tag : undefined
}
44 changes: 44 additions & 0 deletions test/unit/harness-deploy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { harnessDeployDecision, liveVersionId, versionCommit } from '../../scripts/lib/harness-deploy'

describe('harness deploy decision', () => {
it('deploys when a change since the live commit touches the Harness, even if HEAD^ did not', () => {
// #550 changed the Harness, then #552 merged before its deploy ran. HEAD^ to HEAD shows only #552.
expect(harnessDeployDecision({ liveCommit: 'aaa111', changedPaths: ['app/pages/index.vue', 'workers/skill-harness/src/gateway.ts'] }))
.toEqual({ _tag: 'Deploy', reason: 'workers/skill-harness/src/gateway.ts changed since aaa111.' })
})

it('keeps the live version when nothing it builds from changed', () => {
expect(harnessDeployDecision({ liveCommit: 'aaa111', changedPaths: ['app/pages/index.vue', 'docs/runbooks/skillgen.md'] }))
.toEqual({ _tag: 'Keep' })
})

it('deploys when the live version names no commit', () => {
expect(harnessDeployDecision({ liveCommit: undefined, changedPaths: [] }))
.toEqual({ _tag: 'Deploy', reason: 'The live Harness version names no commit.' })
})

it('deploys when the live commit is not in this history, such as a branch deploy', () => {
expect(harnessDeployDecision({ liveCommit: 'bbb222', changedPaths: undefined }))
.toEqual({ _tag: 'Deploy', reason: 'Commit bbb222 is not in this history.' })
})
})

describe('reading wrangler output', () => {
it('reads the newest deployment\'s full-traffic version', () => {
const deployments = [
{ created_on: '2026-10-07T04:27:21Z', versions: [{ version_id: 'old', percentage: 100 }] },
{ created_on: '2026-10-07T04:55:38Z', versions: [{ version_id: 'canary', percentage: 10 }, { version_id: 'new', percentage: 90 }] },
]
expect(liveVersionId(deployments)).toBe('new')
})

it('reads the commit a version was tagged with', () => {
expect(versionCommit({ annotations: { 'workers/tag': '8608395ee6ab', 'workers/triggered_by': 'upload' } })).toBe('8608395ee6ab')
expect(versionCommit({ annotations: { 'workers/triggered_by': 'upload' } })).toBeUndefined()
})

it('rejects a tag that is not a commit', () => {
expect(versionCommit({ annotations: { 'workers/tag': 'main; rm -rf /' } })).toBeUndefined()
})
})
2 changes: 1 addition & 1 deletion workers/skill-harness/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"scripts": {
"build": "wrangler deploy --dry-run --outdir dist",
"deploy:proof": "wrangler deploy",
"deploy:proof": "wrangler deploy --containers-rollout immediate",
"types": "wrangler types worker-configuration.d.ts --env-file .dev.vars.example --env-interface HarnessEnv --include-runtime=false",
"types:check": "wrangler types worker-configuration.d.ts --env-file .dev.vars.example --env-interface HarnessEnv --include-runtime=false --check",
"typecheck": "tsc --noEmit",
Expand Down
Loading