From 234039586d53e36cde90ce899e348b3cfc0c0854 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 7 Oct 2026 15:58:26 +1100 Subject: [PATCH 1/2] fix(deploy): ship the Harness when anything changed since its live commit The harness-proof job compared HEAD^ with HEAD. #550 changed the Harness, then #552 merged and cancelled #550's Test run. The deploy for #552 saw no Harness change, so #550 never reached production until a manual dispatch. Each deploy now tags the version with its commit, and the next main deploy diffs from that commit. Containers roll out at once, so a proof right after a deploy runs the new image. --- .github/workflows/deploy-cloudflare.yml | 32 ++++++++++-------- docs/runbooks/skill-harness-proof.md | 4 ++- scripts/harness-deploy-plan.ts | 37 +++++++++++++++++++++ scripts/lib/harness-deploy.ts | 43 ++++++++++++++++++++++++ test/unit/harness-deploy.test.ts | 44 +++++++++++++++++++++++++ workers/skill-harness/package.json | 2 +- 6 files changed, 146 insertions(+), 16 deletions(-) create mode 100644 scripts/harness-deploy-plan.ts create mode 100644 scripts/lib/harness-deploy.ts create mode 100644 test/unit/harness-deploy.test.ts diff --git a/.github/workflows/deploy-cloudflare.yml b/.github/workflows/deploy-cloudflare.yml index 8358a6ff..6ab28196 100644 --- a/.github/workflows/deploy-cloudflare.yml +++ b/.github/workflows/deploy-cloudflare.yml @@ -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: | @@ -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 }} diff --git a/docs/runbooks/skill-harness-proof.md b/docs/runbooks/skill-harness-proof.md index 5d3763e1..572d8318 100644 --- a/docs/runbooks/skill-harness-proof.md +++ b/docs/runbooks/skill-harness-proof.md @@ -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 Harness 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`. diff --git a/scripts/harness-deploy-plan.ts b/scripts/harness-deploy-plan.ts new file mode 100644 index 00000000..4785d2fb --- /dev/null +++ b/scripts/harness-deploy-plan.ts @@ -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`) diff --git a/scripts/lib/harness-deploy.ts b/scripts/lib/harness-deploy.ts new file mode 100644 index 00000000..b3dd91f2 --- /dev/null +++ b/scripts/lib/harness-deploy.ts @@ -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 | 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 } | undefined)?.annotations?.['workers/tag'] + return typeof tag === 'string' && COMMIT.test(tag) ? tag : undefined +} diff --git a/test/unit/harness-deploy.test.ts b/test/unit/harness-deploy.test.ts new file mode 100644 index 00000000..7bbded30 --- /dev/null +++ b/test/unit/harness-deploy.test.ts @@ -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() + }) +}) diff --git a/workers/skill-harness/package.json b/workers/skill-harness/package.json index f98eee79..33584508 100644 --- a/workers/skill-harness/package.json +++ b/workers/skill-harness/package.json @@ -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", From 2734033de6d93dc3383f6216e21a593c3faef350 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 7 Oct 2026 15:58:37 +1100 Subject: [PATCH 2/2] docs(runbooks): name the Worker change in the deploy rule --- docs/runbooks/skill-harness-proof.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/runbooks/skill-harness-proof.md b/docs/runbooks/skill-harness-proof.md index 572d8318..84749e3e 100644 --- a/docs/runbooks/skill-harness-proof.md +++ b/docs/runbooks/skill-harness-proof.md @@ -11,7 +11,7 @@ The proof produces a file bundle. [GitHub](https://github.com) tag handling and A merge to main deploys the `skilld-harness-proof` Worker and its container image after the site deploy. 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 Harness change. Container instances roll out at once. +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`.