Skip to content

CI fails on pull requests whose changes are already on main ("No changed files to classify") #109

Closed

Description

@cratis-direct

What happened

Run 36380119330 (CI for #108) failed in the changes job, in the Check for code changes step:

No changed files to classify
##[error]Process completed with exit code 2.

Why

The step diffs the pull request base against the merge ref and treats an empty diff as a hard failure:

files=$(git diff --name-only "$BASE" HEAD)
if [[ -z "$files" ]]; then
  echo 'No changed files to classify' >&2
  exit 2
fi

#108 removes Documentation/client-snippets/scenarios/use-current-state-in-a-command/seed-events.md and one line of Documentation/validate-client-snippets.py. Main already carries exactly that change as 99b3a37 ("Drop the orphaned seed-events snippet", merged through #105), so the pull/108/merge ref is tree-identical to its base a268a1f:

$ git diff --name-only a268a1f1931d753920287f7cf50cdfc88fdfa5f0 refs/remotes/pull/108/merge
(empty)

The workflow's own logic then fails the run.

An empty diff is a real, reachable verdict — "this pull request changes nothing against its base" — and not a failure to look: when the base cannot be resolved, git diff already errors and the step fails on its own. Every pull request whose change landed separately (a re-merge, a cherry-pick, or two agents doing the same cleanup, as here) hits this.

Proposed fix

Move the classification into scripts/classify-changes.sh, called by the workflow, so that it:

  • resolves both ends before diffing and keeps exit 2 for a base it cannot resolve or diff, so the classification cannot silently narrow to nothing;
  • reports an empty diff as empty=true with a notice, so every gate skips instead of the run going red;
  • keeps the code and drizzle patterns unchanged;
  • reports how many files it classified.

The docs gate gains && needs.changes.outputs.empty == 'false', and yarn ci runs a new check:changes:self-test (scripts/classify-changes.test.mjs) that seeds both an already-landed change and an unresolvable base, so the empty-diff path and the exit-2 fuse stay covered.

Verified locally:

  • node --test scripts/classify-changes.test.mjs — 12 passing, 0 failing.
  • Running the script against the actual base and merge ref of the failing run prints code=false, drizzle=false, empty=true and exits 0.

Why this is an issue and not a pull request

The automation token available to the session that prepared the fix (cratis-direct[bot]) has no workflows permission, so git refuses to push a commit that touches .github/workflows/ci.yml:

! [remote rejected] (refusing to allow a GitHub App to create or update workflow
  `.github/workflows/ci.yml` without `workflows` permission)

The complete, self-tested change is below as two commits; git am applies them directly.

Patch (2 commits)
From 2e19a0b03e404d92d49041a4821b7f78f805d861 Mon Sep 17 00:00:00 2001
From: "Cratis Stagehand (AI)" <stagehand-ai@users.noreply.github.com>
Date: Mon, 28 Sep 2026 05:19:22 +0000
Subject: [PATCH 1/2] Add a change classifier that reports an empty diff as no
 changes

The CI change-detection lived inline in the workflow and exited 2 when the
diff came back empty, which reds the run for a pull request whose changes
are already on the base branch. Move the classification into a script that
resolves both ends before diffing, keeps exit 2 for a base it cannot resolve
or diff, and reports an empty diff as the verdict 'nothing changed' with a
new empty output. A self-test seeds both cases so the fuse cannot rot.
---
 package.json                      |   3 +-
 scripts/classify-changes.sh       |  86 ++++++++++++++++
 scripts/classify-changes.test.mjs | 163 ++++++++++++++++++++++++++++++
 3 files changed, 251 insertions(+), 1 deletion(-)
 create mode 100755 scripts/classify-changes.sh
 create mode 100644 scripts/classify-changes.test.mjs

diff --git a/package.json b/package.json
index 0951da4..3403f8d 100644
--- a/package.json
+++ b/package.json
@@ -42,11 +42,12 @@
     "check:fetch:bun": "node scripts/check-fetch-bun.mjs",
     "check:fetch:next": "node scripts/check-fetch-next.mjs",
     "check:node-test": "node --test scripts/check-node-test.test.mjs",
+    "check:changes:self-test": "node --test scripts/classify-changes.test.mjs",
     "check:observability-recipes": "node scripts/check-observability-recipes.mjs",
     "set-version": "node scripts/set-version.mjs",
     "test": "vitest run",
     "specs": "yarn test",
-    "ci": "yarn tsc -b Source/Core Source/Testing Source/Chronicle Source/Tools/ProxyGenerator && yarn check:metadata && yarn workspace @cratis/arc.core.sample.tasks generate-proxies && yarn workspace @cratis/arc.sample.library generate-proxies && yarn check:proxies && yarn build && yarn check:declarations && yarn check:declarations:self-test && yarn check:consumers && yarn check:fetch && yarn check:node-test && yarn check:observability-recipes && yarn workspace @cratis/arc.sample.library.web build && yarn lint && yarn typecheck && yarn lint:tasks:arc && yarn test:client-generation:verify && yarn test && yarn workspace @cratis/arc.sample.library test:e2e && yarn test:legacy-decorators && yarn test:decorator-types && yarn docs:lint && yarn docs:snippets:self-test && yarn docs:snippets && yarn docs:examples:self-test && yarn docs:examples && node --test scripts/for_release/*.test.mjs && yarn set-version --check"
+    "ci": "yarn tsc -b Source/Core Source/Testing Source/Chronicle Source/Tools/ProxyGenerator && yarn check:metadata && yarn workspace @cratis/arc.core.sample.tasks generate-proxies && yarn workspace @cratis/arc.sample.library generate-proxies && yarn check:proxies && yarn build && yarn check:declarations && yarn check:declarations:self-test && yarn check:consumers && yarn check:fetch && yarn check:node-test && yarn check:changes:self-test && yarn check:observability-recipes && yarn workspace @cratis/arc.sample.library.web build && yarn lint && yarn typecheck && yarn lint:tasks:arc && yarn test:client-generation:verify && yarn test && yarn workspace @cratis/arc.sample.library test:e2e && yarn test:legacy-decorators && yarn test:decorator-types && yarn docs:lint && yarn docs:snippets:self-test && yarn docs:snippets && yarn docs:examples:self-test && yarn docs:examples && node --test scripts/for_release/*.test.mjs && yarn set-version --check"
   },
   "devDependencies": {
     "@cratis/arc.react": "22.19.1",
diff --git a/scripts/classify-changes.sh b/scripts/classify-changes.sh
new file mode 100755
index 0000000..abb4734
--- /dev/null
+++ b/scripts/classify-changes.sh
@@ -0,0 +1,86 @@
+#!/usr/bin/env bash
+# Copyright (c) Cratis. All rights reserved.
+# Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+# Classifies the files changed between a base ref and HEAD for the CI workflow.
+#
+# Usage: classify-changes.sh [<base-sha>] [<head-ish>]
+#
+# The base may also be passed through the BASE environment variable. Writes
+# code=, drizzle= and empty= lines to $GITHUB_OUTPUT when it is set, and to
+# stdout otherwise. Exit codes follow the repository convention: 0 classified,
+# 2 could not run.
+
+set -euo pipefail
+
+base=${1:-${BASE:-}}
+head=${2:-HEAD}
+
+# Machine-readable outputs; human-readable progress goes through say() so that
+# stdout stays parseable whenever the outputs do not go to $GITHUB_OUTPUT.
+emit() {
+    if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
+        printf '%s\n' "$1" >>"$GITHUB_OUTPUT"
+    else
+        printf '%s\n' "$1"
+    fi
+}
+
+say() {
+    if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
+        printf '%s\n' "$1"
+    else
+        printf '%s\n' "$1" >&2
+    fi
+}
+
+if [[ -z "$base" || "$base" =~ ^0+$ ]]; then
+    say 'No base to diff against; running every gate'
+    emit 'code=true'
+    emit 'drizzle=true'
+    emit 'empty=false'
+    exit 0
+fi
+
+# Resolve both ends first, so a missing base is reported as "could not run"
+# rather than being read as an empty diff and skipping every gate.
+if ! base_commit=$(git rev-parse -q --verify "$base^{commit}") ||
+    ! head_commit=$(git rev-parse -q --verify "$head^{commit}"); then
+    say "Cannot resolve $base or $head in this checkout"
+    exit 2
+fi
+
+if ! files=$(git diff --name-only "$base_commit" "$head_commit"); then
+    say "Cannot diff $base_commit against $head_commit"
+    exit 2
+fi
+
+if [[ -z "$files" ]]; then
+    # Identical trees: everything this ref carries is already in the base, for
+    # instance a change that landed separately. That is the verdict "nothing
+    # changed", not a failure to look, so the gates skip instead of going red.
+    say '::notice::No net changes against the base; skipping the code, docs and Drizzle gates'
+    emit 'code=false'
+    emit 'drizzle=false'
+    emit 'empty=true'
+    exit 0
+fi
+
+code=false
+drizzle=false
+count=0
+while IFS= read -r file; do
+    count=$((count + 1))
+    case "$file" in
+        Source/Drizzle/*|Documentation/sql/observing-postgresql.md|.github/workflows/ci.yml|package.json|yarn.lock) drizzle=true ;;
+    esac
+    case "$file" in
+        Documentation/*|*.md) ;;
+        *) code=true ;;
+    esac
+done <<<"$files"
+
+say "Classified $count changed file(s): code=$code drizzle=$drizzle"
+emit "code=$code"
+emit "drizzle=$drizzle"
+emit 'empty=false'
diff --git a/scripts/classify-changes.test.mjs b/scripts/classify-changes.test.mjs
new file mode 100644
index 0000000..0695e7e
--- /dev/null
+++ b/scripts/classify-changes.test.mjs
@@ -0,0 +1,163 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { test } from 'node:test';
+import { fileURLToPath } from 'node:url';
+
+const script = fileURLToPath(new URL('./classify-changes.sh', import.meta.url));
+
+const tracked = {
+    'Documentation/index.md': '# Arc\n',
+    'Documentation/sql/observing-postgresql.md': '# Observing PostgreSQL\n',
+    '.github/workflows/ci.yml': 'name: CI\n',
+    'package.json': '{}\n',
+    'yarn.lock': '# yarn.lock\n',
+    'Source/Core/index.ts': 'export {};\n',
+    'Source/Drizzle/index.ts': 'export {};\n'
+};
+
+const drizzleTriggers = [
+    'Source/Drizzle/index.ts',
+    'Documentation/sql/observing-postgresql.md',
+    '.github/workflows/ci.yml',
+    'package.json',
+    'yarn.lock'
+];
+
+function git(folder, ...arguments_) {
+    const result = spawnSync('git', ['-C', folder, ...arguments_], { encoding: 'utf8' });
+    assert.equal(result.status, 0, `git ${arguments_.join(' ')} failed: ${result.stderr}`);
+    return result.stdout.trim();
+}
+
+function commit(folder, message) {
+    git(folder, 'add', '-A');
+    git(folder, 'commit', '--quiet', '--allow-empty', '--message', message);
+    return git(folder, 'rev-parse', 'HEAD');
+}
+
+function repository(t) {
+    const folder = mkdtempSync(join(tmpdir(), 'arc-classify-changes-'));
+    t.after(() => rmSync(folder, { recursive: true, force: true }));
+    for (const [file, content] of Object.entries(tracked)) {
+        mkdirSync(dirname(join(folder, file)), { recursive: true });
+        writeFileSync(join(folder, file), content);
+    }
+    git(folder, 'init', '--initial-branch=main');
+    git(folder, 'config', 'user.email', 'ci@cratis');
+    git(folder, 'config', 'user.name', 'CI');
+    commit(folder, 'Initial state');
+    return folder;
+}
+
+function edit(folder, file, content = `${tracked[file] ?? ''}# changed\n`) {
+    mkdirSync(dirname(join(folder, file)), { recursive: true });
+    writeFileSync(join(folder, file), content);
+}
+
+function change(folder, file, content) {
+    const base = git(folder, 'rev-parse', 'HEAD');
+    edit(folder, file, content);
+    commit(folder, `Touch ${file}`);
+    return base;
+}
+
+function classify(folder, base) {
+    return spawnSync('bash', [script, ...(base === undefined ? [] : [base])], {
+        cwd: folder,
+        encoding: 'utf8'
+    });
+}
+
+function outputs(result) {
+    return Object.fromEntries(result.stdout.trim().split('\n').filter(Boolean).map(line => line.split('=')));
+}
+
+test('runs every gate when no base is available', t => {
+    const folder = repository(t);
+    const result = classify(folder);
+    assert.equal(result.status, 0, result.stderr);
+    assert.deepEqual(outputs(result), { code: 'true', drizzle: 'true', empty: 'false' });
+});
+
+test('runs every gate when the base is the all-zero sha', t => {
+    const folder = repository(t);
+    const result = classify(folder, '0'.repeat(40));
+    assert.equal(result.status, 0, result.stderr);
+    assert.deepEqual(outputs(result), { code: 'true', drizzle: 'true', empty: 'false' });
+});
+
+test('classifies a documentation-only change as docs-only', t => {
+    const folder = repository(t);
+    const base = change(folder, 'Documentation/index.md');
+    const result = classify(folder, base);
+    assert.equal(result.status, 0, result.stderr);
+    assert.deepEqual(outputs(result), { code: 'false', drizzle: 'false', empty: 'false' });
+});
+
+test('classifies a source change as a code change', t => {
+    const folder = repository(t);
+    const base = change(folder, 'Source/Core/index.ts');
+    const result = classify(folder, base);
+    assert.equal(result.status, 0, result.stderr);
+    assert.deepEqual(outputs(result), { code: 'true', drizzle: 'false', empty: 'false' });
+});
+
+for (const file of drizzleTriggers) {
+    test(`classifies a change to ${file} as Drizzle-affecting`, t => {
+        const folder = repository(t);
+        const base = change(folder, file);
+        const result = classify(folder, base);
+        assert.equal(result.status, 0, result.stderr);
+        // Only the documented SQL page is documentation; the other triggers are code paths.
+        const code = !file.startsWith('Documentation/');
+        assert.deepEqual(outputs(result), { code: `${code}`, drizzle: 'true', empty: 'false' });
+    });
+}
+
+test('classifies an already-landed change as empty and skips every gate', t => {
+    const folder = repository(t);
+    const snippet = 'Documentation/client-snippets/scenarios/use-current-state-in-a-command/seed-events.md';
+    edit(folder, snippet, '# Snippet\n');
+    const fork = commit(folder, 'Add the seed-events snippet');
+    // The branch departs before main drops the snippet and re-applies the same drop.
+    git(folder, 'checkout', '--quiet', '-b', 'topic', fork);
+    rmSync(join(folder, snippet));
+    commit(folder, 'Drop the seed-events snippet on the branch');
+    git(folder, 'checkout', '--quiet', 'main');
+    rmSync(join(folder, snippet));
+    const base = commit(folder, 'Drop the seed-events snippet on main');
+    git(folder, 'merge', '--quiet', '--no-ff', '--message', 'Merge the branch into main', 'topic');
+    const result = classify(folder, base);
+    assert.equal(result.status, 0, result.stderr);
+    assert.deepEqual(outputs(result), { code: 'false', drizzle: 'false', empty: 'true' });
+});
+
+test('writes the outputs to GITHUB_OUTPUT and keeps stdout for the log', t => {
+    const folder = repository(t);
+    const scratch = mkdtempSync(join(tmpdir(), 'arc-classify-output-'));
+    t.after(() => rmSync(scratch, { recursive: true, force: true }));
+    const githubOutput = join(scratch, 'output');
+    const base = change(folder, 'Source/Core/index.ts');
+    const result = spawnSync('bash', [script, base], {
+        cwd: folder,
+        encoding: 'utf8',
+        env: { ...process.env, GITHUB_OUTPUT: githubOutput }
+    });
+    assert.equal(result.status, 0, result.stderr);
+    assert.match(result.stdout, /Classified 1 changed file\(s\): code=true drizzle=false/);
+    assert.deepEqual(readFileSync(githubOutput, 'utf8').trim().split('\n'),
+        ['code=true', 'drizzle=false', 'empty=false']);
+});
+
+test('reports an unresolvable base as could-not-run instead of skipping the gates', t => {
+    const folder = repository(t);
+    const result = classify(folder, 'f'.repeat(40));
+    assert.equal(result.status, 2);
+    assert.match(result.stderr, /Cannot resolve/);
+});
-- 
2.43.0


From 0f64dd322f347f71673a1c05a430139447965fdd Mon Sep 17 00:00:00 2001
From: "Cratis Stagehand (AI)" <stagehand-ai@users.noreply.github.com>
Date: Mon, 28 Sep 2026 05:19:31 +0000
Subject: [PATCH 2/2] Stop the CI changes job failing on pull requests with no
 net changes

A pull request whose changes already landed on the base branch produces a
merge ref whose tree is identical to the base, so the classify step found
no changed files and exited 2, failing the run (run 36380119330, PR #108).
Run the classifier script instead and skip the docs gate when it reports an
empty diff, so an already-landed change skips every gate instead of going
red.
---
 .github/workflows/ci.yml | 28 +++-------------------------
 1 file changed, 3 insertions(+), 25 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d663d37..99b8e35 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,6 +23,7 @@ jobs:
     outputs:
       code: ${{ steps.classify.outputs.code }}
       drizzle: ${{ steps.classify.outputs.drizzle }}
+      empty: ${{ steps.classify.outputs.empty }}
     steps:
       - name: Check out code
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -33,34 +34,11 @@ jobs:
         id: classify
         env:
           BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
-        run: |
-          if [[ "${{ github.event_name }}" == workflow_dispatch || "$BASE" =~ ^0+$ ]]; then
-            echo 'code=true' >> "$GITHUB_OUTPUT"
-            echo 'drizzle=true' >> "$GITHUB_OUTPUT"
-            exit 0
-          fi
-          files=$(git diff --name-only "$BASE" HEAD)
-          if [[ -z "$files" ]]; then
-            echo 'No changed files to classify' >&2
-            exit 2
-          fi
-          code=false
-          drizzle=false
-          while IFS= read -r file; do
-            case "$file" in
-              Source/Drizzle/*|Documentation/sql/observing-postgresql.md|.github/workflows/ci.yml|package.json|yarn.lock) drizzle=true ;;
-            esac
-            case "$file" in
-              Documentation/*|*.md) ;;
-              *) code=true ;;
-            esac
-          done <<< "$files"
-          echo "code=$code" >> "$GITHUB_OUTPUT"
-          echo "drizzle=$drizzle" >> "$GITHUB_OUTPUT"
+        run: bash scripts/classify-changes.sh "$BASE"
 
   docs:
     needs: changes
-    if: needs.changes.outputs.code == 'false'
+    if: needs.changes.outputs.code == 'false' && needs.changes.outputs.empty == 'false'
     runs-on: ubuntu-latest
     timeout-minutes: 30
     steps:
-- 
2.43.0

Disposition of #108

#108 is redundant: its content is already on main as 99b3a37. It can be closed as such, or it will go green once this fix lands and CI runs again on it.

Activity

  1. self-assigned this
    on Sep 29, 2026
  2. woksin commented on Sep 29, 2026

    @woksin
    Contributor

    Fixed in #138: an empty diff now selects no jobs instead of failing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions