You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/02 15:05:53 UTC

(maka) branch test/windows-repro-probe updated: test(release): isolate differences in existing Windows artifacts

This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch test/windows-repro-probe
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/test/windows-repro-probe by this push:
     new 7afdc25c88 test(release): isolate differences in existing Windows artifacts
7afdc25c88 is described below

commit 7afdc25c88ef2fb42fe401cbe6fed90068801343
Author: AstroHan <le...@outlook.com>
AuthorDate: Wed Sep 2 23:05:42 2026 +0800

    test(release): isolate differences in existing Windows artifacts
    
    Analyze the retained experiment artifacts on a runner, verifying every original manifest hash before inspection. Compare ASAR file contents and PE sections, isolating checkout paths, timestamps, CodeView data, and embedded ASAR hashes only in memory. Upload a small diagnostic report rather than downloading full installers locally. No product build, signing, or publication behavior changes.
    
    Generated-by: Codex
---
 .github/workflows/release-windows-check.yml |  41 +++-
 scripts/diagnose-windows-repro.mjs          | 317 ++++++++++++++++++++++++++++
 scripts/diagnose-windows-repro.test.mjs     | 119 +++++++++++
 3 files changed, 476 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/release-windows-check.yml b/.github/workflows/release-windows-check.yml
index 5a58427ba8..b230dcdabb 100644
--- a/.github/workflows/release-windows-check.yml
+++ b/.github/workflows/release-windows-check.yml
@@ -107,6 +107,10 @@ on:
         description: Compare two clean unsigned builds without publishing or signing
         type: boolean
         default: false
+      diagnose_existing:
+        description: Diagnose artifacts from the completed Windows comparison run
+        type: boolean
+        default: false
 
 permissions:
   contents: read
@@ -121,7 +125,7 @@ concurrency:
 
 jobs:
   package:
-    if: ${{ !inputs.reproducibility_probe }}
+    if: ${{ !inputs.reproducibility_probe && !inputs.diagnose_existing }}
     runs-on: windows-2025
     timeout-minutes: 45
     defaults:
@@ -343,3 +347,38 @@ jobs:
           appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
           if (environmentDifferences.length || differences.length) process.exitCode = 1;
           NODE
+
+  reproducibility-diagnose:
+    if: ${{ github.event_name == 'workflow_dispatch' && inputs.diagnose_existing }}
+    runs-on: ubuntu-24.04
+    timeout-minutes: 45
+    permissions:
+      contents: read
+      actions: read
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+        with:
+          persist-credentials: false
+
+      - name: Verify the existing experiment source
+        env:
+          GH_TOKEN: ${{ github.token }}
+        run: |
+          test "$(gh api repos/apache/maka/actions/runs/33643012549 --jq .head_sha)" = 48b0408003edcf9594c38e2cb1bfc79beb4283b5
+
+      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          pattern: windows-repro-*
+          path: evidence
+          run-id: 33643012549
+          github-token: ${{ github.token }}
+
+      - name: Diagnose original bytes without rebuilding or executing them
+        run: node scripts/diagnose-windows-repro.mjs evidence > windows-repro-diagnosis.json
+
+      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+        with:
+          name: windows-repro-diagnosis
+          path: windows-repro-diagnosis.json
+          if-no-files-found: error
+          retention-days: 7
diff --git a/scripts/diagnose-windows-repro.mjs b/scripts/diagnose-windows-repro.mjs
new file mode 100644
index 0000000000..f01bab9ae6
--- /dev/null
+++ b/scripts/diagnose-windows-repro.mjs
@@ -0,0 +1,317 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import { readFileSync } from 'node:fs';
+import { join, resolve, sep } from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+const sourceCommit = '48b0408003edcf9594c38e2cb1bfc79beb4283b5';
+const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
+
+function under(root, path) {
+  const result = resolve(root, path);
+  assert.ok(result.startsWith(`${resolve(root)}${sep}`), `Path escapes artifact: ${path}`);
+  return result;
+}
+
+function replaceEqualLength(bytes, needle, replacement) {
+  assert.equal(needle.length, replacement.length);
+  let count = 0;
+  for (
+    let offset = bytes.indexOf(needle);
+    offset !== -1;
+    offset = bytes.indexOf(needle, offset + needle.length)
+  ) {
+    replacement.copy(bytes, offset);
+    count++;
+  }
+  return count;
+}
+
+function normalizeCheckout(bytes, sample) {
+  const result = Buffer.from(bytes);
+  let replacements = 0;
+  for (const encoding of ['utf8', 'utf16le']) {
+    for (const token of [`sample-${sample}`, `SAMPLE-${sample.toUpperCase()}`]) {
+      replacements += replaceEqualLength(
+        result,
+        Buffer.from(token, encoding),
+        Buffer.from(token.slice(0, -1) + 'X', encoding),
+      );
+    }
+  }
+  return { bytes: result, replacements };
+}
+
+function differingRanges(a, b) {
+  const ranges = [];
+  let count = 0;
+  let current;
+  for (let offset = 0; offset < Math.max(a.length, b.length); offset++) {
+    if (a[offset] === b[offset]) {
+      current = undefined;
+      continue;
+    }
+    count++;
+    if (current) current.length++;
+    else {
+      current = { offset, length: 1 };
+      if (ranges.length < 16) ranges.push(current);
+    }
+  }
+  return {
+    count,
+    firstRanges: ranges.map((range) => ({
+      ...range,
+      a: a.subarray(range.offset, range.offset + Math.min(range.length, 24)).toString('hex'),
+      b: b.subarray(range.offset, range.offset + Math.min(range.length, 24)).toString('hex'),
+    })),
+  };
+}
+
+export function readPe(bytes) {
+  assert.equal(bytes.toString('ascii', 0, 2), 'MZ');
+  const pe = bytes.readUInt32LE(0x3c);
+  assert.equal(bytes.toString('ascii', pe, pe + 4), 'PE\0\0');
+  const optional = pe + 24;
+  const sectionTable = optional + bytes.readUInt16LE(pe + 20);
+  const sections = [];
+  for (let index = 0; index < bytes.readUInt16LE(pe + 6); index++) {
+    const start = sectionTable + index * 40;
+    const section = {
+      name: bytes.toString('ascii', start, start + 8).replace(/\0.*$/u, ''),
+      rva: bytes.readUInt32LE(start + 12),
+      size: bytes.readUInt32LE(start + 16),
+      offset: bytes.readUInt32LE(start + 20),
+    };
+    assert.ok(section.offset + section.size <= bytes.length);
+    sections.push(section);
+  }
+  const atRva = (rva) => {
+    const section = sections.find((entry) => rva >= entry.rva && rva < entry.rva + entry.size);
+    assert.ok(section, `Unmapped PE RVA ${rva}`);
+    return section.offset + rva - section.rva;
+  };
+  const directory = optional + (bytes.readUInt16LE(optional) === 0x20b ? 112 : 96);
+  const debugRva = bytes.readUInt32LE(directory + 6 * 8);
+  const debugSize = bytes.readUInt32LE(directory + 6 * 8 + 4);
+  const metadataRanges = [
+    [pe + 8, 4],
+    [optional + 64, 4],
+  ];
+  const debug = [];
+  for (let index = 0; debugRva && index + 28 <= debugSize; index += 28) {
+    const entry = atRva(debugRva) + index;
+    const type = bytes.readUInt32LE(entry + 12);
+    const size = bytes.readUInt32LE(entry + 16);
+    const offset = bytes.readUInt32LE(entry + 24);
+    assert.ok(offset + size <= bytes.length);
+    metadataRanges.push([entry + 4, 4]);
+    const info = { type, timestamp: bytes.readUInt32LE(entry + 4), size, offset };
+    if (type === 2 && size >= 24 && bytes.toString('ascii', offset, offset + 4) === 'RSDS') {
+      info.guid = bytes.subarray(offset + 4, offset + 20).toString('hex');
+      info.age = bytes.readUInt32LE(offset + 20);
+      info.pdb = bytes.toString('utf8', offset + 24, offset + size).replace(/\0.*$/u, '');
+      metadataRanges.push([offset, size]);
+    }
+    debug.push(info);
+  }
+  return {
+    timestamp: bytes.readUInt32LE(pe + 8),
+    checksum: bytes.readUInt32LE(optional + 64),
+    sections,
+    debug,
+    metadataRanges,
+  };
+}
+
+export function comparePe(a, b, asarHeaderHashes = []) {
+  const parsed = [a, b].map(readPe);
+  const normalized = [a, b].map((bytes, index) => normalizeCheckout(bytes, ['a', 'b'][index]));
+  const pathOnlyEqual = normalized[0].bytes.equals(normalized[1].bytes);
+  for (const [index, entry] of parsed.entries()) {
+    for (const [offset, size] of entry.metadataRanges)
+      normalized[index].bytes.fill(0, offset, offset + size);
+  }
+  const metadataAndPathsEqual = normalized[0].bytes.equals(normalized[1].bytes);
+  const integrityReplacements = normalized.map((entry, index) =>
+    asarHeaderHashes[index]
+      ? replaceEqualLength(entry.bytes, Buffer.from(asarHeaderHashes[index]), Buffer.alloc(64, 48))
+      : 0,
+  );
+  return {
+    size: [a.length, b.length],
+    timestamps: parsed.map((entry) => entry.timestamp),
+    debug: parsed.map((entry) => entry.debug),
+    pathReplacements: normalized.map((entry) => entry.replacements),
+    pathOnlyEqual,
+    metadataAndPathsEqual,
+    integrityReplacements,
+    metadataPathsAndAsarIntegrityEqual: normalized[0].bytes.equals(normalized[1].bytes),
+    remainingDifferences: differingRanges(normalized[0].bytes, normalized[1].bytes),
+    sections: parsed[0].sections.map((section) => {
+      const other = parsed[1].sections.find((entry) => entry.name === section.name);
+      const content = a.subarray(section.offset, section.offset + section.size);
+      return {
+        name: section.name,
+        size: [section.size, other?.size],
+        rawEqual: !!other && content.equals(b.subarray(other.offset, other.offset + other.size)),
+        diagnosticEqual:
+          !!other &&
+          normalized[0].bytes
+            .subarray(section.offset, section.offset + section.size)
+            .equals(normalized[1].bytes.subarray(other.offset, other.offset + other.size)),
+      };
+    }),
+  };
+}
+
+export function inspectAsar(archive) {
+  const bytes = readFileSync(archive);
+  const dataStart = 8 + bytes.readUInt32LE(4);
+  const jsonEnd = 16 + bytes.readUInt32LE(12);
+  assert.ok(jsonEnd <= dataStart && dataStart <= bytes.length);
+  const headerString = bytes.toString('utf8', 16, jsonEnd);
+  const header = JSON.parse(headerString);
+  const files = new Map();
+  function walk(directory, prefix = '') {
+    for (const [name, entry] of Object.entries(directory.files)) {
+      const path = prefix + name;
+      if (entry.files) walk(entry, `${path}/`);
+      else if (entry.link) files.set(path, { link: entry.link });
+      else {
+        const start = dataStart + Number(entry.offset ?? 0);
+        assert.ok(entry.unpacked || start + entry.size <= bytes.length);
+        const data = entry.unpacked
+          ? readFileSync(under(`${archive}.unpacked`, path))
+          : bytes.subarray(start, start + entry.size);
+        assert.equal(data.length, entry.size);
+        files.set(path, { sha256: hash(data), unpacked: !!entry.unpacked, header: entry });
+      }
+    }
+  }
+  walk(header);
+  return { files, headerHash: hash(headerString), dataHash: hash(bytes.subarray(dataStart)) };
+}
+
+function textEvidence(a, b) {
+  const normalized = [a, b].map((bytes, index) => normalizeCheckout(bytes, ['a', 'b'][index]));
+  const texts = [a, b].map((bytes) =>
+    bytes.toString(bytes[0] === 255 && bytes[1] === 254 ? 'utf16le' : 'utf8').split(/\r?\n/u),
+  );
+  const lines = [];
+  for (
+    let index = 0;
+    index < Math.max(...texts.map((text) => text.length)) && lines.length < 4;
+    index++
+  ) {
+    if (texts[0][index] !== texts[1][index])
+      lines.push({
+        line: index + 1,
+        a: texts[0][index]?.slice(0, 320),
+        b: texts[1][index]?.slice(0, 320),
+      });
+  }
+  return {
+    pathOnlyEqual: normalized[0].bytes.equals(normalized[1].bytes),
+    pathReplacements: normalized.map((entry) => entry.replacements),
+    lines,
+  };
+}
+
+export function diagnose(root) {
+  const samples = ['a', 'b'].map((sample) => {
+    const evidence = join(root, `windows-repro-evidence-${sample}`);
+    const environment = JSON.parse(readFileSync(join(evidence, 'environment.json')));
+    assert.equal(environment.sourceCommit, sourceCommit);
+    const files = new Map(
+      JSON.parse(readFileSync(join(evidence, 'files.json'))).map((file) => [file.path, file]),
+    );
+    const payload = join(root, `windows-repro-payload-${sample}`);
+    for (const [path, file] of files) {
+      const bytes = readFileSync(under(payload, path));
+      assert.equal(bytes.length, file.size, `Artifact size mismatch: ${sample}/${path}`);
+      assert.equal(hash(bytes), file.sha256, `Artifact hash mismatch: ${sample}/${path}`);
+    }
+    return { environment, files, payload };
+  });
+  const paths = [...new Set(samples.flatMap((sample) => [...sample.files.keys()]))].sort();
+  const differences = paths.filter(
+    (path) => samples[0].files.get(path)?.sha256 !== samples[1].files.get(path)?.sha256,
+  );
+  const asars = samples.map((sample) =>
+    inspectAsar(join(sample.payload, 'win-unpacked/resources/app.asar')),
+  );
+  const asarPaths = [...new Set(asars.flatMap((asar) => [...asar.files.keys()]))].sort();
+  const asarChanges = asarPaths.flatMap((path) => {
+    const [a, b] = asars.map((asar) => asar.files.get(path));
+    if (JSON.stringify(a) === JSON.stringify(b)) return [];
+    return [
+      {
+        path,
+        present: [!!a, !!b],
+        unpacked: [a?.unpacked, b?.unpacked],
+        contentEqual: !!a && !!b && a.sha256 === b.sha256 && a.link === b.link,
+      },
+    ];
+  });
+  const binary = [];
+  const text = [];
+  for (const path of differences) {
+    if (!samples.every((sample) => sample.files.has(path))) continue;
+    if (
+      !path.startsWith('win-unpacked/') ||
+      !/\.(exe|node|vcxproj|filters|tlog|recipe|lastbuildstate)$/u.test(path)
+    )
+      continue;
+    const bytes = samples.map((sample) => readFileSync(under(sample.payload, path)));
+    if (/\.(node|exe)$/u.test(path))
+      binary.push({
+        path,
+        ...comparePe(
+          ...bytes,
+          asars.map((asar) => asar.headerHash),
+        ),
+      });
+    else text.push({ path, ...textEvidence(...bytes) });
+  }
+  return {
+    sourceCommit,
+    verifiedOriginalManifestHashes: true,
+    environments: samples.map((sample) => sample.environment),
+    differingPaths: differences.length,
+    onlyA: differences.filter((path) => !samples[1].files.has(path)),
+    onlyB: differences.filter((path) => !samples[0].files.has(path)),
+    asar: {
+      fileCounts: asars.map((asar) => asar.files.size),
+      headerHashes: asars.map((asar) => asar.headerHash),
+      rawDataEqual: asars[0].dataHash === asars[1].dataHash,
+      changes: asarChanges,
+    },
+    binary,
+    text,
+    note: 'All normalization is in-memory diagnostic isolation, not a release acceptance rule. Original artifacts are never modified or executed.',
+  };
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+  console.log(JSON.stringify(diagnose(resolve(process.argv[2])), null, 2));
+}
diff --git a/scripts/diagnose-windows-repro.test.mjs b/scripts/diagnose-windows-repro.test.mjs
new file mode 100644
index 0000000000..94da72672e
--- /dev/null
+++ b/scripts/diagnose-windows-repro.test.mjs
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { test } from 'node:test';
+import { createPackageWithOptions } from '@electron/asar';
+import { comparePe, inspectAsar } from './diagnose-windows-repro.mjs';
+
+function pe(sample, timestamp) {
+  const bytes = Buffer.alloc(0x800);
+  bytes.write('MZ');
+  bytes.writeUInt32LE(0x80, 0x3c);
+  bytes.write('PE\0\0', 0x80);
+  bytes.writeUInt16LE(2, 0x86);
+  bytes.writeUInt32LE(timestamp, 0x88);
+  bytes.writeUInt16LE(240, 0x94);
+  bytes.writeUInt16LE(0x20b, 0x98);
+  bytes.writeUInt32LE(0x2000, 0x98 + 112 + 48);
+  bytes.writeUInt32LE(28, 0x98 + 112 + 52);
+  for (const [index, name, rva, offset] of [
+    [0, '.text', 0x1000, 0x200],
+    [1, '.rdata', 0x2000, 0x400],
+  ]) {
+    const section = 0x98 + 240 + index * 40;
+    bytes.write(name, section);
+    bytes.writeUInt32LE(rva, section + 12);
+    bytes.writeUInt32LE(0x200, section + 16);
+    bytes.writeUInt32LE(offset, section + 20);
+  }
+  bytes.write('unchanged instructions', 0x200);
+  bytes.writeUInt32LE(timestamp, 0x404);
+  bytes.writeUInt32LE(2, 0x40c);
+  bytes.writeUInt32LE(80, 0x410);
+  bytes.writeUInt32LE(0x440, 0x418);
+  bytes.write('RSDS', 0x440);
+  bytes.fill(timestamp, 0x444, 0x454);
+  bytes.writeUInt32LE(1, 0x454);
+  bytes.write(`D:\\sample-${sample}\\module.pdb\0`, 0x458);
+  return bytes;
+}
+
+test('isolates PE timestamps and CodeView data without changing the original bytes', () => {
+  const a = pe('a', 1);
+  const b = pe('b', 2);
+  const before = Buffer.from(a);
+  const result = comparePe(a, b);
+  assert.equal(result.pathOnlyEqual, false);
+  assert.equal(result.metadataAndPathsEqual, true);
+  assert.equal(result.sections.find((section) => section.name === '.text').rawEqual, true);
+  assert.deepEqual(a, before);
+  assert.deepEqual(result.timestamps, [1, 2]);
+  assert.match(result.debug[1][0].pdb, /sample-b/u);
+});
+
+test('does not hide differing machine instructions behind PE metadata normalization', () => {
+  const a = pe('a', 1);
+  const b = pe('b', 2);
+  b[0x210] ^= 1;
+  const result = comparePe(a, b);
+  assert.equal(result.metadataAndPathsEqual, false);
+  assert.equal(result.metadataPathsAndAsarIntegrityEqual, false);
+  assert.equal(result.sections.find((section) => section.name === '.text').rawEqual, false);
+});
+
+test('identifies an exact embedded ASAR hash difference separately from other metadata', () => {
+  const a = pe('a', 1);
+  const b = pe('b', 2);
+  const hashes = ['a'.repeat(64), 'b'.repeat(64)];
+  a.write(hashes[0], 0x580);
+  b.write(hashes[1], 0x580);
+  const result = comparePe(a, b, hashes);
+  assert.equal(result.metadataAndPathsEqual, false);
+  assert.equal(result.metadataPathsAndAsarIntegrityEqual, true);
+  assert.deepEqual(result.integrityReplacements, [1, 1]);
+});
+
+test('hashes actual packed and unpacked ASAR file contents using Electron archives', async (context) => {
+  const root = mkdtempSync(join(tmpdir(), 'windows-repro-test-'));
+  context.after(() => rmSync(root, { recursive: true, force: true }));
+  const input = join(root, 'input');
+  mkdirSync(input);
+  writeFileSync(join(input, 'main.js'), 'same javascript');
+  writeFileSync(join(input, 'native.node'), 'native a');
+  const a = join(root, 'a.asar');
+  await createPackageWithOptions(input, a, { unpack: '*.node' });
+  writeFileSync(join(input, 'native.node'), 'native b');
+  const b = join(root, 'b.asar');
+  await createPackageWithOptions(input, b, { unpack: '*.node' });
+  const [left, right] = [a, b].map(inspectAsar);
+  assert.equal(
+    left.files.get('main.js').sha256,
+    createHash('sha256').update('same javascript').digest('hex'),
+  );
+  assert.equal(left.files.get('main.js').sha256, right.files.get('main.js').sha256);
+  assert.equal(left.files.get('native.node').unpacked, true);
+  assert.notEqual(left.files.get('native.node').sha256, right.files.get('native.node').sha256);
+  assert.equal(left.dataHash, right.dataHash);
+  assert.notEqual(left.headerHash, right.headerHash);
+});


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org