mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valery Kharseko
23 hours ago 91fcddb2d3bc7be859d60c062089ed947f480dd1
Refresh the Windows launchers only when their code changes, not their toolchain stamp (#1103)
3 files modified
3 files added
360 ■■■■■ changed files
.github/scripts/refresh-launchers.sh 35 ●●●●● patch | view | raw | blame | history
.github/scripts/same-pe-code.py 163 ●●●●● patch | view | raw | blame | history
.github/scripts/test-refresh-launchers.sh 121 ●●●●● patch | view | raw | blame | history
.github/workflows/build.yml 13 ●●●●● patch | view | raw | blame | history
.github/workflows/deploy.yml 23 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/build-tools/windows/Makefile 5 ●●●●● patch | view | raw | blame | history
.github/scripts/refresh-launchers.sh
New file
@@ -0,0 +1,35 @@
#!/bin/bash
#
# The contents of this file are subject to the terms of the Common Development and
# Distribution License (the License). You may not use this file except in compliance with the
# License.
#
# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
# specific language governing permission and limitations under the License.
#
# When distributing Covered Software, include this CDDL Header Notice in each file and include
# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
# Header, with the fields enclosed by brackets [] replaced by your own identifying
# information: "Portions copyright [year] [name of copyright owner]".
#
# Copyright 2026 3A Systems, LLC.
# Usage: refresh-launchers.sh BUILT LIB
#
# Copies each BUILT/*.exe over its namesake in LIB, unless the two differ only in the
# toolchain stamp (same-pe-code.py). The Package/Deploy workflow runs it before it
# commits LIB; the Build workflow runs it on known launcher pairs before merge.
set -e
here=$(dirname "$0")
for built in "$1"/*.exe; do
  committed=$2/$(basename "$built")
  # Exit 1 (the code differs) and 2 (not readable as a PE image) both take the rebuilt
  # file: when in doubt, refresh.
  if [ -f "$committed" ] && python3 "$here/same-pe-code.py" "$committed" "$built"; then
    if ! cmp -s "$committed" "$built"; then
      echo "$committed differs from the rebuilt one only in the toolchain stamp - keeping it."
    fi
    continue
  fi
  cp "$built" "$committed"
done
.github/scripts/same-pe-code.py
New file
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
#
# The contents of this file are subject to the terms of the Common Development and
# Distribution License (the License). You may not use this file except in compliance with the
# License.
#
# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
# specific language governing permission and limitations under the License.
#
# When distributing Covered Software, include this CDDL Header Notice in each file and include
# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
# Header, with the fields enclosed by brackets [] replaced by your own identifying
# information: "Portions copyright [year] [name of copyright owner]".
#
# Copyright 2026 3A Systems, LLC.
"""Tell whether two PE images differ in anything but the stamp of the toolchain.
Usage: same-pe-code.py COMMITTED REBUILT
Exits 0 when the two files are identical once the build stamp and the header padding
are left out of both, 1 when they differ anywhere else, and 2 when either one cannot be
read as a PE image.
The Windows launchers are linked with /Brepro, so their bytes are a function of the
inputs - and the inputs include the build numbers of cl, link and cvtres. Two runner
images a patch release of Visual Studio apart (14.51.36256 and 14.51.36257, say) turn
out byte-for-byte the same code, data and resources, yet different files: the Rich
header lists those build numbers, the REPRO debug entry holds a hash over them, and
the COFF and debug directory timestamps and the PE checksum are derived from that
hash. GitHub rolls a new image out over days, so the Windows job lands on either one
and a byte comparison refreshes the committed launchers back and forth on every push.
What is left out of the comparison:
- the Rich header;
- the COFF and debug directory timestamps and the PE checksum;
- the REPRO hash, and the PDB GUID and age of an RSDS CodeView entry;
- the zero padding the linker puts after the DOS stub, up to the PE header, and after
  the section table, up to SizeOfHeaders, and e_lfanew, which only says where the first
  of them ends. A patch release may pad differently:
  14.51.36252 put the PE header at 0x100, 14.51.36256 at 0xf0, around the same code.
The DOS stub, the PE headers, the section table and every byte from SizeOfHeaders on
are still compared, so a change of source or of code generation still shows. A Rich
header that gains or loses an entry no longer does on its own: it comes with a change
of the objects linked in, which moves the code as well.
"""
import struct
import sys
DEBUG_TYPE_CODEVIEW = 2
DEBUG_TYPE_REPRO = 16
DEBUG_ENTRY_SIZE = 28
class NotPE(Exception):
    pass
def u16(b, off):
    return struct.unpack_from("<H", b, off)[0]
def u32(b, off):
    return struct.unpack_from("<I", b, off)[0]
def blank(b, off, length):
    if off < 0 or off + length > len(b):
        raise NotPE("field at 0x%x runs past the end of the file" % off)
    b[off:off + length] = bytes(length)
def normalize(data):
    b = bytearray(data)
    try:
        if b[:2] != b"MZ":
            raise NotPE("no MZ signature")
        pe = u32(b, 0x3C)
        if b[pe:pe + 4] != b"PE\0\0":
            raise NotPE("no PE signature")
        # The Rich header sits between the DOS stub and the PE header: "DanS" XOR key,
        # the (prodId, build, count) records XOR key, then "Rich" and the key itself.
        rich = b.find(b"Rich", 0x40, pe)
        if rich >= 0:
            key = b[rich + 4:rich + 8]
            dans = bytes(x ^ y for x, y in zip(b"DanS", key))
            start = b.rfind(dans, 0x40, rich)
            if start < 0:
                raise NotPE("Rich header without its DanS marker")
            blank(b, start, rich + 8 - start)
        coff = pe + 4
        sections = u16(b, coff + 2)
        opt_size = u16(b, coff + 16)
        blank(b, coff + 4, 4)                                    # TimeDateStamp
        opt = coff + 20
        magic = u16(b, opt)
        if magic == 0x10B:
            data_dirs = opt + 96
        elif magic == 0x20B:
            data_dirs = opt + 112
        else:
            raise NotPE("unknown optional header magic 0x%x" % magic)
        blank(b, opt + 64, 4)                                    # CheckSum
        headers_end = u32(b, opt + 60)                           # SizeOfHeaders
        if not pe < headers_end <= len(b):
            raise NotPE("SizeOfHeaders 0x%x out of range" % headers_end)
        table = opt + opt_size
        spans = []
        for i in range(sections):
            s = table + 40 * i
            spans.append((u32(b, s + 12), u32(b, s + 8), u32(b, s + 20)))  # va, vsize, raw
        def file_offset(rva):
            for va, vsize, raw in spans:
                if va <= rva < va + vsize:
                    return raw + rva - va
            raise NotPE("RVA 0x%x lies in no section" % rva)
        debug_rva = u32(b, data_dirs + 6 * 8)
        debug_size = u32(b, data_dirs + 6 * 8 + 4)
        if debug_rva and debug_size:
            base = file_offset(debug_rva)
            for i in range(debug_size // DEBUG_ENTRY_SIZE):
                entry = base + DEBUG_ENTRY_SIZE * i
                blank(b, entry + 4, 4)                           # TimeDateStamp
                kind = u32(b, entry + 12)
                size = u32(b, entry + 16)
                raw = u32(b, entry + 24)
                if kind == DEBUG_TYPE_REPRO:
                    blank(b, raw, size)                          # the hash itself
                elif kind == DEBUG_TYPE_CODEVIEW and b[raw:raw + 4] == b"RSDS":
                    blank(b, raw + 4, 20)                        # PDB GUID and age
    except struct.error as e:
        raise NotPE(str(e))
    # Compare what the padding surrounds, wherever it ends (see above). The Rich header
    # is blanked by now, so it goes with the padding after the DOS stub; e_lfanew goes
    # too, since it only says where that padding ends.
    return (bytes(b[:0x3C]) + bytes(b[0x40:pe]).rstrip(b"\0")
            + bytes(b[pe:headers_end]).rstrip(b"\0") + bytes(b[headers_end:]))
def main(argv):
    if len(argv) != 3:
        print(__doc__.strip().splitlines()[2], file=sys.stderr)
        return 2
    try:
        images = []
        for path in argv[1:]:
            with open(path, "rb") as f:
                images.append(normalize(f.read()))
    except (OSError, NotPE) as e:
        print("same-pe-code: %s" % e, file=sys.stderr)
        return 2
    return 0 if images[0] == images[1] else 1
if __name__ == "__main__":
    sys.exit(main(sys.argv))
.github/scripts/test-refresh-launchers.sh
New file
@@ -0,0 +1,121 @@
#!/bin/bash
#
# The contents of this file are subject to the terms of the Common Development and
# Distribution License (the License). You may not use this file except in compliance with the
# License.
#
# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
# specific language governing permission and limitations under the License.
#
# When distributing Covered Software, include this CDDL Header Notice in each file and include
# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
# Header, with the fields enclosed by brackets [] replaced by your own identifying
# information: "Portions copyright [year] [name of copyright owner]".
#
# Copyright 2026 3A Systems, LLC.
# Checks same-pe-code.py and refresh-launchers.sh against launchers committed in the
# history of master, so that neither can start keeping a changed launcher, or refreshing
# one that differs only in the toolchain stamp, while CI stays green. Needs the full
# history (fetch-depth: 0). Run from the root of the repository.
set -e
here=$(dirname "$0")
t=$(mktemp -d)
trap 'rm -rf "$t"' EXIT
g() { git show "$1:opendj-server-legacy/lib/$2.exe" > "$t/$2-$3.exe"; }
expect() {
  local rc=0
  python3 "$here/same-pe-code.py" "$t/$2" "$t/$3" || rc=$?
  if [ "$rc" != "$1" ]; then
    echo "::error::same-pe-code.py $2 $3 exited $rc, expected $1 ($4)"
    exit 1
  fi
  echo "ok: $2 $3 -> $rc ($4)"
}
for f in launcher_administrator winlauncher opendj_service; do
  g a611c10c46^ $f 36252
  g a611c10c46 $f 36256
  g a8a18f000d $f 36257
  g 74cb4f5a84 $f old-code
  expect 0 $f-36256.exe $f-36257.exe "the toolchain stamp only"
  expect 0 $f-36257.exe $f-36256.exe "the toolchain stamp only"
  expect 1 $f-old-code.exe $f-36257.exe "before and after a change of the launcher sources"
done
# winlauncher.exe has CheckSum 0 in both builds, launcher_administrator.exe does not.
for f in launcher_administrator winlauncher; do
  expect 0 $f-36252.exe $f-36256.exe "the same code, the PE header at 0x100 and at 0xf0"
done
expect 1 opendj_service-36252.exe opendj_service-36256.exe "a code change of the 05.09 refresh"
expect 1 winlauncher-36257.exe launcher_administrator-36257.exe "two different launchers"
# A real change moves the headers too; this one does not, so the sections must be compared.
python3 - "$t/winlauncher-36257.exe" "$t/winlauncher-text-byte.exe" <<'EOF'
import struct, sys
b = bytearray(open(sys.argv[1], "rb").read())
pe = struct.unpack_from("<I", b, 0x3C)[0]
table = pe + 24 + struct.unpack_from("<H", b, pe + 20)[0]
b[struct.unpack_from("<I", b, table + 20)[0] + 16] ^= 1    # the first section: .text
open(sys.argv[2], "wb").write(b)
EOF
expect 1 winlauncher-36257.exe winlauncher-text-byte.exe "one byte of .text flipped"
# A linker flag, the DOS stub or a section flag changes the headers only, so they must be
# compared as well.
for field in nx stub section; do
  python3 - "$t/winlauncher-36257.exe" "$t/winlauncher-$field.exe" $field <<'EOF'
import struct, sys
b = bytearray(open(sys.argv[1], "rb").read())
pe = struct.unpack_from("<I", b, 0x3C)[0]
table = pe + 24 + struct.unpack_from("<H", b, pe + 20)[0]
off = {"nx": pe + 24 + 71,                  # DllCharacteristics, high byte: NX_COMPAT
       "stub": 0x4E,                        # "This program cannot be run in DOS mode."
       "section": table + 39}[sys.argv[3]]  # the first section's Characteristics, top byte
b[off] ^= 1
open(sys.argv[2], "wb").write(b)
EOF
  expect 1 winlauncher-36257.exe winlauncher-$field.exe "$field: a change of the headers only"
done
# No committed launcher has a PDB. Retype the POGO debug entry as CodeView in two copies
# whose PDB GUID and age differ, and nothing else.
for n in 1 2; do
  python3 - "$t/winlauncher-36257.exe" "$t/winlauncher-rsds$n.exe" $n <<'EOF'
import struct, sys
b = bytearray(open(sys.argv[1], "rb").read())
pe = struct.unpack_from("<I", b, 0x3C)[0]
opt = pe + 24
dirs = opt + (96 if struct.unpack_from("<H", b, opt)[0] == 0x10B else 112)
table = opt + struct.unpack_from("<H", b, pe + 20)[0]
rva = struct.unpack_from("<I", b, dirs + 6 * 8)[0]            # the debug directory
for i in range(struct.unpack_from("<H", b, pe + 6)[0]):
    va, vsize, raw = (struct.unpack_from("<I", b, table + 40 * i + o)[0] for o in (12, 8, 20))
    if va <= rva < va + vsize:
        entry = raw + rva - va
while struct.unpack_from("<I", b, entry + 12)[0] != 13:       # the POGO entry
    entry += 28
struct.pack_into("<I", b, entry + 12, 2)                       # retyped as CodeView
data = struct.unpack_from("<I", b, entry + 24)[0]
b[data:data + 24] = b"RSDS" + bytes([int(sys.argv[3])]) * 20  # GUID and age differ
open(sys.argv[2], "wb").write(b)
EOF
done
expect 0 winlauncher-rsds1.exe winlauncher-rsds2.exe "the PDB GUID and age of a CodeView entry"
head -c 64 "$t/winlauncher-36257.exe" > "$t/truncated.exe"
expect 2 truncated.exe winlauncher-36257.exe "a file that is not a PE image"
# The loop: keeps a launcher that differs only in the stamp, refreshes one whose code
# changed, adds one that is not committed yet, and refreshes one whose committed file
# is not a PE image.
mkdir "$t/built" "$t/lib"
cp "$t/winlauncher-36257.exe" "$t/built/winlauncher.exe"
cp "$t/winlauncher-36256.exe" "$t/lib/winlauncher.exe"
cp "$t/launcher_administrator-36257.exe" "$t/built/launcher_administrator.exe"
cp "$t/launcher_administrator-old-code.exe" "$t/lib/launcher_administrator.exe"
cp "$t/opendj_service-36257.exe" "$t/built/opendj_service.exe"
cp "$t/winlauncher-36257.exe" "$t/built/unreadable.exe"
cp "$t/truncated.exe" "$t/lib/unreadable.exe"
bash "$here/refresh-launchers.sh" "$t/built" "$t/lib"
cmp "$t/lib/winlauncher.exe" "$t/winlauncher-36256.exe"
cmp "$t/lib/launcher_administrator.exe" "$t/built/launcher_administrator.exe"
cmp "$t/lib/opendj_service.exe" "$t/built/opendj_service.exe"
cmp "$t/lib/unreadable.exe" "$t/built/unreadable.exe"
echo "ok: refresh-launchers.sh keeps, refreshes and adds as expected"
.github/workflows/build.yml
@@ -56,6 +56,12 @@
      with:
        fetch-depth: 0
        submodules: recursive
    # deploy.yml runs these two scripts only after a push build, so this is the check they
    # get before merge. It needs the history above: it compares launchers committed on master.
    - name: Check same-pe-code.py and refresh-launchers.sh against committed launchers
      if: runner.os == 'Linux' && matrix.java == '11'
      shell: bash
      run: bash .github/scripts/test-refresh-launchers.sh
    - name: Java ${{ matrix.Java }} (${{ matrix.os }})
      uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
      with:
@@ -86,9 +92,10 @@
        git status
    # Also the source of truth for the committed opendj-server-legacy/lib/*.exe: on a
    # successful push build, deploy.yml downloads windows-exe-11 from this very run and
    # commits its contents back to the branch. Nothing here compares them with what is
    # committed - an MSVC toolchain bump on the runner image changes the bytes on its own,
    # so a byte-for-byte gate would fire without a source change.
    # commits each launcher that differs from the committed one in more than the toolchain
    # stamp (.github/scripts/refresh-launchers.sh). Nothing here compares them with what
    # is committed - an MSVC toolchain bump on the runner image changes the bytes on its
    # own, so a byte-for-byte gate would fire without a source change.
    - name: Upload Windows exe artifacts
      if: runner.os == 'Windows'
      uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
.github/workflows/deploy.yml
@@ -76,10 +76,17 @@
      #
      # This only works because the Makefile passes /Brepro to both cl and link: the output
      # is a function of the sources, not of the build time. Without it every run would
      # produce different bytes and this would commit on every push. An MSVC toolchain bump
      # on the runner image does change them, and that refresh commit is correct - the
      # committed binary then matches what CI verifies. Pushes made with GITHUB_TOKEN do
      # not start new workflow runs, so this cannot loop; a PAT would break that.
      # produce different bytes and this would commit on every push. The inputs it hashes
      # include the build numbers of the tools, though, so a Visual Studio patch release
      # on the runner image changes the stamp of the files - Rich header, REPRO hash,
      # timestamps, checksum, header padding - while the code comes out the same. GitHub
      # rolls a new image out over days, and while old and new both serve windows-latest
      # the launchers were refreshed back and forth on every push (a8a18f000d, then
      # c6919eaaf4, which undid it). refresh-launchers.sh compares the files with that
      # stamp left out (same-pe-code.py), so only a change of the code, data or resources
      # is committed - from a source change or from a toolchain that really generates
      # different code. Pushes made with GITHUB_TOKEN do not start new workflow runs, so
      # this cannot loop; a PAT would break that.
      - name: Download the launchers built by the triggering Build run
        continue-on-error: true
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -103,7 +110,13 @@
            echo "::warning title=No launcher binaries from the Build run::windows-exe-11 could not be downloaded, leaving opendj-server-legacy/lib/*.exe as committed."
            exit 0
          fi
          cp "$BUILT"/*.exe opendj-server-legacy/lib/
          # A checked-out branch that predates the script (sustaining/4.10.x, say) takes
          # the rebuilt files as they are, which is the old behaviour.
          if [ -f .github/scripts/refresh-launchers.sh ]; then
            bash .github/scripts/refresh-launchers.sh "$BUILT" opendj-server-legacy/lib
          else
            cp "$BUILT"/*.exe opendj-server-legacy/lib/
          fi
          # status --porcelain, not diff: it reports a brand-new launcher that was never
          # git-added just as well as a modified one.
          if [ -z "$(git status --porcelain -- opendj-server-legacy/lib)" ]; then
opendj-server-legacy/src/build-tools/windows/Makefile
@@ -39,8 +39,9 @@
WINLAUNCHER_PROGNAME=winlauncher.exe
# /Brepro makes the outputs reproducible (content-hash PE timestamps instead of the
# build time). The Package/Deploy workflow commits these binaries back to the branch
# whenever their bytes differ from the committed ones; without /Brepro every build
# would differ and it would commit on every push.
# whenever they differ from the committed ones in more than the toolchain stamp
# (.github/scripts/same-pe-code.py); without /Brepro every build would differ and it
# would commit on every push.
LINKER=link -nologo /machine:x86 /Brepro
LIBS=advapi32.lib