You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/08/20 03:10:10 UTC

(maka) branch feat/asf-source-release created (now 12ac3e7b5)

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

M4n5ter pushed a change to branch feat/asf-source-release
in repository https://gitbox.apache.org/repos/asf/maka.git


      at 12ac3e7b5 fix(release): harden ASF source candidate trust

This branch includes the following new commits:

     new 12ac3e7b5 fix(release): harden ASF source candidate trust

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



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


(maka) 01/01: fix(release): harden ASF source candidate trust

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/asf-source-release
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 12ac3e7b579557adf33eb80bcbe674ee0fa2c636
Author: Wang <m4...@gmail.com>
AuthorDate: Thu Aug 20 11:09:32 2026 +0800

    fix(release): harden ASF source candidate trust
    
    Bind local signing to a byte-for-byte rebuild from the reviewed revision, require exact signing-key fingerprints, and interpret GPG status records so expired or revoked signatures fail closed. Isolate source mechanics in their own required CI lane, make tar access portable across Windows paths, and keep package and lockfile release identities aligned. The runbook now reflects the trusted-hardware handoff and every binding voter's verification duty.
---
 .github/ASF_SOURCE_RELEASE.md               |  32 +++-
 .github/workflows/asf-source-candidate.yml  |  34 ++--
 .github/workflows/ci.yml                    |  23 ++-
 package.json                                |   3 +-
 scripts/asf-source-release.mjs              | 257 +++++++++++++++++++++-------
 scripts/asf-source-release.test.mjs         | 153 ++++++++++++++++-
 scripts/asf-source-workflow-policy.test.mjs |  42 +++++
 scripts/ci-test-plan.mjs                    |  18 ++
 8 files changed, 466 insertions(+), 96 deletions(-)

diff --git a/.github/ASF_SOURCE_RELEASE.md b/.github/ASF_SOURCE_RELEASE.md
index eb1ee6b7f..79a9c4fa5 100644
--- a/.github/ASF_SOURCE_RELEASE.md
+++ b/.github/ASF_SOURCE_RELEASE.md
@@ -66,7 +66,8 @@ and publish it through the normal reviewed Git process:
 
 ```sh
 git tag -s v<version>-incubating-rc<rc> <full-commit-sha>
-git show v<version>-incubating-rc<rc>
+git verify-tag v<version>-incubating-rc<rc>
+git show --no-patch --format=fuller v<version>-incubating-rc<rc>^{commit}
 ```
 
 Pushing the tag is a separate authenticated maintainer action. Confirm its
@@ -75,15 +76,24 @@ target and signature before publishing it; the automation does not push tags.
 ## Sign locally
 
 Never place a Release Manager's private PGP key in GitHub Actions or the
-repository. Download the unsigned workflow artifact, compare its SHA-512 with
-the workflow output, and sign it on the Release Manager's machine:
+repository. Download the unsigned workflow artifact and its SHA-512 file onto
+the Release Manager's machine. In a clean, trusted checkout containing the
+verified candidate tag, run:
 
 ```sh
 npm run release:asf:sign -- \
   --artifact <candidate-dir>/apache-maka-<version>-incubating-src.tar.gz \
-  --key <full-pgp-fingerprint>
+  --key <full-pgp-fingerprint> \
+  --revision v<version>-incubating-rc<rc>
 ```
 
+The signing command first validates the downloaded SHA-512 and archive, rebuilds
+the archive from the specified revision on the Release Manager's hardware, and
+requires the rebuilt and downloaded archives to be byte-for-byte identical. It
+then resolves the selected secret key to the exact full fingerprint before
+creating the detached signature. A workflow-produced digest alone is not an
+independent trust check and is insufficient for signing.
+
 Export the matching public key for the podling `KEYS` file when needed:
 
 ```sh
@@ -100,10 +110,12 @@ from the reviewed `KEYS` file:
 ```sh
 npm run release:asf:verify -- \
   --artifact <candidate-dir>/apache-maka-<version>-incubating-src.tar.gz \
-  --require-signature \
   --keys <path-to-reviewed-KEYS>
 ```
 
+Supplying `--keys` requires a detached signature. Verification rejects expired
+or revoked keys and signatures as well as bad or missing signatures.
+
 ## Stage on Apache dist/dev
 
 Release Managers need ASF commit access to the distribution repository. Check
@@ -125,10 +137,12 @@ the vote email.
 
 ## Independent verification
 
-At least one voter other than the Release Manager should download all candidate
-files and the published `KEYS` over HTTPS, run the repository verifier, inspect
-the archive, and build/test from the extracted source. Voters should record the
-commit, SHA-512, signing-key fingerprint, platform, and commands used.
+Before casting a binding `+1`, every voter must download all signed source
+packages and the published `KEYS` over HTTPS onto their own hardware, validate
+ASF release-policy compliance and all cryptographic signatures, inspect the
+archive, and compile/test the extracted source. Non-binding voters are strongly
+encouraged to perform the same checks. Voters should record the commit, SHA-512,
+signing-key fingerprint, platform, and commands used.
 
 ## Podling vote template
 
diff --git a/.github/workflows/asf-source-candidate.yml b/.github/workflows/asf-source-candidate.yml
index d2e0e6c3e..049b15cd3 100644
--- a/.github/workflows/asf-source-candidate.yml
+++ b/.github/workflows/asf-source-candidate.yml
@@ -24,12 +24,15 @@ jobs:
     name: Build and test source archive
     runs-on: ubuntu-24.04
     timeout-minutes: 60
+    env:
+      CANDIDATE_PATH: release/asf/apache-maka-${{ inputs.version }}-incubating-src.tar.gz
+      RC_NUMBER: ${{ inputs.rc_number }}
+      RELEASE_SHA: ${{ github.sha }}
+      RELEASE_VERSION: ${{ inputs.version }}
     steps:
       - name: Require main and valid release identity
         env:
-          RC_NUMBER: ${{ inputs.rc_number }}
           RELEASE_REF: ${{ github.ref }}
-          RELEASE_VERSION: ${{ inputs.version }}
         run: |
           if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then
             echo "ASF source candidates must be dispatched from main; found $RELEASE_REF" >&2
@@ -47,7 +50,7 @@ jobs:
       - name: Check out the candidate commit
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
         with:
-          ref: ${{ github.sha }}
+          ref: ${{ env.RELEASE_SHA }}
           fetch-depth: 0
           persist-credentials: false
 
@@ -72,27 +75,20 @@ jobs:
           fi
 
       - name: Create the unsigned source candidate
-        env:
-          RELEASE_SHA: ${{ github.sha }}
-          RELEASE_VERSION: ${{ inputs.version }}
         run: |
           npm run release:asf:source -- \
             --version "$RELEASE_VERSION" \
             --revision "$RELEASE_SHA"
 
       - name: Verify archive structure and SHA-512
-        env:
-          RELEASE_VERSION: ${{ inputs.version }}
         run: |
           npm run release:asf:verify -- \
-            --artifact "release/asf/apache-maka-${RELEASE_VERSION}-incubating-src.tar.gz"
+            --artifact "$CANDIDATE_PATH"
 
       - name: Extract the exact candidate
-        env:
-          RELEASE_VERSION: ${{ inputs.version }}
         run: |
           mkdir candidate-source
-          tar -xzf "release/asf/apache-maka-${RELEASE_VERSION}-incubating-src.tar.gz" \
+          tar -xzf "$CANDIDATE_PATH" \
             --strip-components=1 \
             -C candidate-source
 
@@ -116,9 +112,9 @@ jobs:
           npm run build
           npm run typecheck
 
-      - name: Run release checks from the extracted source
+      - name: Run ASF source checks from the extracted source
         working-directory: candidate-source
-        run: npm run check:release
+        run: npm run check:asf-source
 
       - name: Test the extracted source
         working-directory: candidate-source
@@ -130,8 +126,8 @@ jobs:
         with:
           name: apache-maka-${{ inputs.version }}-incubating-rc${{ inputs.rc_number }}-unsigned
           path: |
-            release/asf/apache-maka-${{ inputs.version }}-incubating-src.tar.gz
-            release/asf/apache-maka-${{ inputs.version }}-incubating-src.tar.gz.sha512
+            ${{ env.CANDIDATE_PATH }}
+            ${{ env.CANDIDATE_PATH }}.sha512
           if-no-files-found: error
           compression-level: 0
           retention-days: 30
@@ -139,9 +135,6 @@ jobs:
       - name: Record Release Manager handoff
         env:
           ARTIFACT_URL: ${{ steps.candidate.outputs.artifact-url }}
-          RC_NUMBER: ${{ inputs.rc_number }}
-          RELEASE_SHA: ${{ github.sha }}
-          RELEASE_VERSION: ${{ inputs.version }}
         run: |
           {
             echo "## Unsigned ASF source candidate"
@@ -152,6 +145,7 @@ jobs:
             echo "- Artifact: $ARTIFACT_URL"
             echo
             echo "This workflow does not create an ASF release candidate by itself."
-            echo "A Release Manager must sign the archive locally, verify it against KEYS,"
+            echo "A Release Manager must reproduce these exact bytes from the recorded commit,"
+            echo "sign the archive locally, verify it against KEYS,"
             echo "and stage the immutable archive, .sha512, and .asc files on Apache dist/dev."
           } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a54250adf..de1221aa9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,6 +14,7 @@ jobs:
   changes:
     runs-on: ubuntu-latest
     outputs:
+      asf_source: ${{ steps.plan.outputs.asf_source }}
       astryx_surface: ${{ steps.plan.outputs.astryx_surface }}
       code: ${{ steps.plan.outputs.code }}
       e2e: ${{ steps.plan.outputs.e2e }}
@@ -44,6 +45,23 @@ jobs:
             node scripts/ci-test-plan.mjs --base "$BASE_SHA" --head "$HEAD_SHA" >> "$GITHUB_OUTPUT"
           fi
 
+  asf_source:
+    needs: changes
+    if: needs.changes.outputs.asf_source == 'true'
+    runs-on: ubuntu-24.04
+    permissions:
+      contents: read
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+        with:
+          fetch-depth: 0
+          persist-credentials: false
+      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+        with:
+          node-version: '24'
+      - name: Verify ASF source release mechanics
+        run: npm run check:asf-source
+
   astryx_surface:
     needs: changes
     if: needs.changes.outputs.astryx_surface == 'true'
@@ -153,12 +171,14 @@ jobs:
   # Preserve one stable required check while letting independent heavy suites
   # occupy their own runners. The aggregator waits for every selected lane.
   test:
-    needs: [changes, test_workspaces, test_runtime_host]
+    needs: [changes, asf_source, test_workspaces, test_runtime_host]
     if: always()
     runs-on: ubuntu-latest
     steps:
       - name: Require successful test lanes
         env:
+          ASF_SOURCE_RESULT: ${{ needs.asf_source.result }}
+          ASF_SOURCE_SELECTED: ${{ needs.changes.outputs.asf_source }}
           CHANGES_RESULT: ${{ needs.changes.result }}
           RUNTIME_HOST_RESULT: ${{ needs.test_runtime_host.result }}
           RUNTIME_HOST_SELECTED: ${{ needs.changes.outputs.runtime_host }}
@@ -186,6 +206,7 @@ jobs:
           if [[ -n "$STANDARD_WORKSPACES" ]]; then
             workspaces_selected="true"
           fi
+          require_lane "ASF source release" "$ASF_SOURCE_SELECTED" "$ASF_SOURCE_RESULT"
           require_lane "workspace tests" "$workspaces_selected" "$WORKSPACES_RESULT"
           require_lane "Runtime Host tests" "$RUNTIME_HOST_SELECTED" "$RUNTIME_HOST_RESULT"
 
diff --git a/package.json b/package.json
index 17984152f..b90d63203 100644
--- a/package.json
+++ b/package.json
@@ -48,9 +48,10 @@
     "release:asf:source": "node scripts/asf-source-release.mjs create",
     "release:asf:verify": "node scripts/asf-source-release.mjs verify",
     "release:asf:sign": "node scripts/asf-source-release.mjs sign",
+    "check:asf-source": "node --test scripts/asf-source-release.test.mjs scripts/asf-source-workflow-policy.test.mjs",
     "generate:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs",
     "check:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs --check",
-    "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && node --test scripts/asf-source-release.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs",
+    "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && node --test scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs",
     "package:macos-arm64": "node scripts/package-macos-arm64.mjs",
     "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs",
     "package:windows-x64": "node scripts/package-windows-x64.mjs",
diff --git a/scripts/asf-source-release.mjs b/scripts/asf-source-release.mjs
index dd1e51c58..6af99c696 100644
--- a/scripts/asf-source-release.mjs
+++ b/scripts/asf-source-release.mjs
@@ -1,4 +1,4 @@
-import { execFileSync } from 'node:child_process';
+import { execFileSync, spawnSync } from 'node:child_process';
 import { createHash } from 'node:crypto';
 import {
   chmodSync,
@@ -12,7 +12,7 @@ import {
   writeFileSync,
 } from 'node:fs';
 import { tmpdir } from 'node:os';
-import { basename, join, resolve } from 'node:path';
+import { basename, dirname, join, resolve } from 'node:path';
 import { fileURLToPath } from 'node:url';
 
 const defaultRepoRoot = resolve(import.meta.dirname, '..');
@@ -23,6 +23,18 @@ const requiredRootFiles = [...requiredReleaseDocuments, 'package.json', 'package
 const forbiddenSegments = new Set(['.agents', '.claude', '.git', '.maka-shots', 'node_modules']);
 const forbiddenRootFiles = new Set(['maka-proposal-zh-review.txt']);
 const maxCommandBuffer = 64 * 1024 * 1024;
+const rejectedGpgStatuses = new Set([
+  'BADSIG',
+  'ERRSIG',
+  'EXPKEYSIG',
+  'EXPSIG',
+  'FAILURE',
+  'KEYEXPIRED',
+  'KEYREVOKED',
+  'NODATA',
+  'NO_PUBKEY',
+  'REVKEYSIG',
+]);
 
 export function sourceCandidateIdentity(version) {
   if (!versionPattern.test(version) || version.includes('incubating')) {
@@ -94,11 +106,8 @@ export async function createSourceCandidate({
   const identity = sourceCandidateIdentity(version);
   const commit = git(repositoryRoot, ['rev-parse', '--verify', `${revision}^{commit}`]).trim();
   const packageJson = JSON.parse(git(repositoryRoot, ['show', `${commit}:package.json`]));
-  if (packageJson.version !== version) {
-    throw new Error(
-      `Version ${version} does not match package.json at ${commit}: ${packageJson.version}`,
-    );
-  }
+  const packageLock = JSON.parse(git(repositoryRoot, ['show', `${commit}:package-lock.json`]));
+  validatePackageVersions({ packageJson, packageLock, version, source: `commit ${commit}` });
 
   validateTrackedNames(repositoryRoot, commit);
   mkdirSync(outputDirectory, { recursive: true, mode: 0o755 });
@@ -139,7 +148,6 @@ export async function createSourceCandidate({
 export async function verifySourceCandidate({
   archivePath,
   keysPath,
-  requireSignature = false,
   signaturePath = `${archivePath}.asc`,
 }) {
   const archiveName = basename(archivePath);
@@ -156,15 +164,14 @@ export async function verifySourceCandidate({
     throw new Error(`SHA-512 mismatch for ${archiveName}`);
   }
 
-  const entries = execFileSync('tar', ['-tzf', archivePath], {
+  const entries = execTar(archivePath, ['-tzf'], {
     encoding: 'utf8',
     maxBuffer: maxCommandBuffer,
   }).split(/\r?\n/);
   validateArchiveEntries(entries, identity.rootDirectory);
   validateArchiveContents(archivePath, identity);
 
-  if (requireSignature || keysPath) {
-    if (!keysPath) throw new Error('--keys is required when verifying a signature');
+  if (keysPath) {
     if (!existsSync(signaturePath)) {
       throw new Error(`Detached signature does not exist: ${signaturePath}`);
     }
@@ -174,31 +181,73 @@ export async function verifySourceCandidate({
   return { ...identity, archivePath, checksumPath, digest: actualDigest, signaturePath };
 }
 
-export async function signSourceCandidate({ archivePath, keyFingerprint }) {
-  if (!/^[0-9a-fA-F]{16,64}$/.test(keyFingerprint)) {
-    throw new Error('A full or long-form hexadecimal PGP key fingerprint is required');
+export async function reproduceSourceCandidate({
+  archivePath,
+  repositoryRoot = defaultRepoRoot,
+  revision,
+}) {
+  if (!revision) throw new Error('An immutable revision is required to reproduce a candidate');
+  const candidate = await verifySourceCandidate({ archivePath });
+  const temporaryRoot = mkdtempSync(join(tmpdir(), 'maka-asf-reproduce-'));
+  try {
+    const reproduced = await createSourceCandidate({
+      outputDirectory: temporaryRoot,
+      repositoryRoot,
+      revision,
+      version: candidate.version,
+    });
+    if (!readFileSync(archivePath).equals(readFileSync(reproduced.archivePath))) {
+      throw new Error(`Candidate bytes do not match ${reproduced.commit} rebuilt on this machine`);
+    }
+    return { ...candidate, commit: reproduced.commit };
+  } finally {
+    rmSync(temporaryRoot, { force: true, recursive: true });
+  }
+}
+
+export async function signSourceCandidate({
+  archivePath,
+  gpgHome,
+  keyFingerprint,
+  repositoryRoot = defaultRepoRoot,
+  revision,
+}) {
+  if (!/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/.test(keyFingerprint)) {
+    throw new Error('A complete hexadecimal PGP key fingerprint is required');
+  }
+  const normalizedFingerprint = keyFingerprint.toUpperCase();
+  const selectedFingerprint = resolveSigningKeyFingerprint(normalizedFingerprint, gpgHome);
+  if (selectedFingerprint !== normalizedFingerprint) {
+    throw new Error(
+      `Selected signing key ${selectedFingerprint} does not match ${normalizedFingerprint}`,
+    );
   }
-  await verifySourceCandidate({ archivePath });
+  const reproduced = await reproduceSourceCandidate({ archivePath, repositoryRoot, revision });
   const signaturePath = `${archivePath}.asc`;
   if (existsSync(signaturePath)) {
     throw new Error(`Refusing to overwrite existing detached signature: ${signaturePath}`);
   }
-  execFileSync(
-    'gpg',
-    [
-      '--armor',
-      '--detach-sign',
-      '--local-user',
-      keyFingerprint,
-      '--output',
-      signaturePath,
-      archivePath,
-    ],
-    { stdio: 'inherit' },
-  );
-  chmodSync(signaturePath, 0o644);
-  execFileSync('gpg', ['--verify', signaturePath, archivePath], { stdio: 'inherit' });
-  return signaturePath;
+  try {
+    execFileSync(
+      'gpg',
+      [
+        '--armor',
+        '--detach-sign',
+        '--local-user',
+        normalizedFingerprint,
+        '--output',
+        signaturePath,
+        archivePath,
+      ],
+      { stdio: 'inherit', ...gpgHomeOption(gpgHome) },
+    );
+    chmodSync(signaturePath, 0o644);
+    verifyGpgSignature({ archivePath, gpgHome, signaturePath });
+  } catch (error) {
+    rmSync(signaturePath, { force: true });
+    throw error;
+  }
+  return { commit: reproduced.commit, signaturePath };
 }
 
 function git(repositoryRoot, arguments_, options = {}) {
@@ -221,6 +270,21 @@ function validateTrackedNames(repositoryRoot, commit) {
   }
 }
 
+export function validatePackageVersions({ packageJson, packageLock, source, version }) {
+  const versions = [
+    ['package.json', packageJson.version],
+    ['package-lock.json', packageLock.version],
+    ['package-lock.json packages[""]', packageLock.packages?.['']?.version],
+  ];
+  for (const [name, actualVersion] of versions) {
+    if (actualVersion !== version) {
+      throw new Error(
+        `Version ${version} does not match ${name} in ${source}: ${String(actualVersion)}`,
+      );
+    }
+  }
+}
+
 async function writeSha512File(archivePath, checksumPath) {
   const digest = await sha512(archivePath);
   writeFileSync(checksumPath, `${digest}  ${basename(archivePath)}\n`, {
@@ -237,33 +301,38 @@ async function sha512(path) {
 
 function validateArchiveContents(archivePath, identity) {
   for (const requiredFile of requiredReleaseDocuments) {
-    const contents = execFileSync(
-      'tar',
-      ['-xOzf', archivePath, `${identity.rootDirectory}/${requiredFile}`],
-      { encoding: 'utf8', maxBuffer: maxCommandBuffer },
-    );
+    const contents = execTar(archivePath, ['-xOzf', `${identity.rootDirectory}/${requiredFile}`], {
+      encoding: 'utf8',
+      maxBuffer: maxCommandBuffer,
+    });
     if (!contents.trim()) throw new Error(`${requiredFile} is empty`);
   }
-  const disclaimer = execFileSync(
-    'tar',
-    ['-xOzf', archivePath, `${identity.rootDirectory}/DISCLAIMER-WIP`],
-    { encoding: 'utf8', maxBuffer: maxCommandBuffer },
-  );
+  const disclaimer = execTar(archivePath, ['-xOzf', `${identity.rootDirectory}/DISCLAIMER-WIP`], {
+    encoding: 'utf8',
+    maxBuffer: maxCommandBuffer,
+  });
   if (!disclaimer.includes('Apache Maka') || !disclaimer.includes('incubation')) {
     throw new Error('DISCLAIMER-WIP does not identify Apache Maka as an incubating project');
   }
 
   const packageJson = JSON.parse(
-    execFileSync('tar', ['-xOzf', archivePath, `${identity.rootDirectory}/package.json`], {
+    execTar(archivePath, ['-xOzf', `${identity.rootDirectory}/package.json`], {
       encoding: 'utf8',
       maxBuffer: maxCommandBuffer,
     }),
   );
-  if (packageJson.version !== identity.version) {
-    throw new Error(
-      `Archive version ${identity.version} does not match package.json: ${packageJson.version}`,
-    );
-  }
+  const packageLock = JSON.parse(
+    execTar(archivePath, ['-xOzf', `${identity.rootDirectory}/package-lock.json`], {
+      encoding: 'utf8',
+      maxBuffer: maxCommandBuffer,
+    }),
+  );
+  validatePackageVersions({
+    packageJson,
+    packageLock,
+    source: basename(archivePath),
+    version: identity.version,
+  });
 }
 
 function verifyDetachedSignature({ archivePath, keysPath, signaturePath }) {
@@ -274,27 +343,90 @@ function verifyDetachedSignature({ archivePath, keysPath, signaturePath }) {
     execFileSync('gpg', ['--batch', '--homedir', temporaryHome, '--import', keysPath], {
       stdio: 'inherit',
     });
-    execFileSync(
-      'gpg',
-      ['--batch', '--homedir', temporaryHome, '--verify', signaturePath, archivePath],
-      { stdio: 'inherit' },
-    );
+    verifyGpgSignature({ archivePath, gpgHome: temporaryHome, signaturePath });
   } finally {
     rmSync(temporaryHome, { force: true, recursive: true });
   }
 }
 
+function execTar(archivePath, arguments_, options) {
+  return execFileSync('tar', [arguments_[0], basename(archivePath), ...arguments_.slice(1)], {
+    cwd: dirname(archivePath),
+    ...options,
+  });
+}
+
+function gpgHomeOption(gpgHome) {
+  return gpgHome ? { env: { ...process.env, GNUPGHOME: gpgHome } } : {};
+}
+
+function resolveSigningKeyFingerprint(keyFingerprint, gpgHome) {
+  const output = execFileSync(
+    'gpg',
+    ['--batch', '--with-colons', '--fingerprint', '--list-secret-keys', keyFingerprint],
+    { encoding: 'utf8', maxBuffer: maxCommandBuffer, ...gpgHomeOption(gpgHome) },
+  );
+  let expectsPrimaryFingerprint = false;
+  const primaryFingerprints = [];
+  for (const line of output.split(/\r?\n/)) {
+    const fields = line.split(':');
+    if (fields[0] === 'sec') {
+      expectsPrimaryFingerprint = true;
+      continue;
+    }
+    if (fields[0] === 'fpr' && expectsPrimaryFingerprint) {
+      primaryFingerprints.push(fields[9]?.toUpperCase());
+      expectsPrimaryFingerprint = false;
+    }
+  }
+  if (primaryFingerprints.length !== 1 || !primaryFingerprints[0]) {
+    throw new Error(`Expected exactly one secret key for fingerprint ${keyFingerprint}`);
+  }
+  return primaryFingerprints[0];
+}
+
+export function validateGpgVerificationStatus(statusOutput) {
+  const statuses = statusOutput
+    .split(/\r?\n/)
+    .filter((line) => line.startsWith('[GNUPG:] '))
+    .map((line) => line.slice('[GNUPG:] '.length).split(' '));
+  const rejected = statuses.find(([status]) => rejectedGpgStatuses.has(status));
+  if (rejected) throw new Error(`GPG rejected the signature with status ${rejected[0]}`);
+  const goodSignatures = statuses.filter(([status]) => status === 'GOODSIG');
+  const validSignatures = statuses.filter(([status]) => status === 'VALIDSIG');
+  if (goodSignatures.length !== 1 || validSignatures.length !== 1) {
+    throw new Error('GPG did not report exactly one good, valid signature');
+  }
+  return {
+    fingerprint: validSignatures[0][1],
+    primaryFingerprint: validSignatures[0][10] ?? validSignatures[0][1],
+  };
+}
+
+function verifyGpgSignature({ archivePath, gpgHome, signaturePath }) {
+  const result = spawnSync(
+    'gpg',
+    ['--batch', '--status-fd', '1', '--verify', signaturePath, archivePath],
+    {
+      encoding: 'utf8',
+      maxBuffer: maxCommandBuffer,
+      ...gpgHomeOption(gpgHome),
+    },
+  );
+  if (result.stderr) process.stderr.write(result.stderr);
+  if (result.error) throw result.error;
+  const signature = validateGpgVerificationStatus(result.stdout);
+  if (result.status !== 0)
+    throw new Error(`GPG verification failed with exit code ${result.status}`);
+  return signature;
+}
+
 function parseCommandLine(arguments_) {
   const [command, ...tokens] = arguments_;
   const options = new Map();
   for (let index = 0; index < tokens.length; index += 1) {
     const token = tokens[index];
     if (!token.startsWith('--')) throw new Error(`Unexpected argument: ${token}`);
-    if (token === '--require-signature') {
-      if (options.has('require-signature')) throw new Error(`Duplicate option: ${token}`);
-      options.set('require-signature', true);
-      continue;
-    }
     const value = tokens[index + 1];
     if (!value || value.startsWith('--')) throw new Error(`Missing value for ${token}`);
     const name = token.slice(2);
@@ -333,23 +465,24 @@ async function main() {
     return;
   }
   if (command === 'verify') {
-    validateOptions(options, new Set(['artifact', 'keys', 'require-signature']));
+    validateOptions(options, new Set(['artifact', 'keys']));
     const result = await verifySourceCandidate({
       archivePath: resolve(requireOption(options, 'artifact')),
       keysPath: options.get('keys') ? resolve(options.get('keys')) : undefined,
-      requireSignature: options.get('require-signature') === true,
     });
     console.log(`Verified ${result.archivePath}`);
     console.log(`SHA-512 ${result.digest}`);
     return;
   }
   if (command === 'sign') {
-    validateOptions(options, new Set(['artifact', 'key']));
-    const signaturePath = await signSourceCandidate({
+    validateOptions(options, new Set(['artifact', 'key', 'revision']));
+    const result = await signSourceCandidate({
       archivePath: resolve(requireOption(options, 'artifact')),
       keyFingerprint: requireOption(options, 'key'),
+      revision: requireOption(options, 'revision'),
     });
-    console.log(`Created ${signaturePath}`);
+    console.log(`Reproduced commit ${result.commit}`);
+    console.log(`Created ${result.signaturePath}`);
     return;
   }
   throw new Error('Usage: asf-source-release.mjs <create|verify|sign> [options]');
diff --git a/scripts/asf-source-release.test.mjs b/scripts/asf-source-release.test.mjs
index 462b409f7..84898cdd7 100644
--- a/scripts/asf-source-release.test.mjs
+++ b/scripts/asf-source-release.test.mjs
@@ -1,14 +1,26 @@
 import assert from 'node:assert/strict';
 import { execFileSync } from 'node:child_process';
-import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
+import {
+  chmodSync,
+  mkdtempSync,
+  mkdirSync,
+  readFileSync,
+  rmSync,
+  symlinkSync,
+  writeFileSync,
+} from 'node:fs';
 import { tmpdir } from 'node:os';
-import { join } from 'node:path';
+import { basename, dirname, join } from 'node:path';
 import { describe, test } from 'node:test';
 import {
   createSourceCandidate,
   parseSha512File,
+  reproduceSourceCandidate,
+  signSourceCandidate,
   sourceCandidateIdentity,
   validateArchiveEntries,
+  validateGpgVerificationStatus,
+  validatePackageVersions,
   verifySourceCandidate,
 } from './asf-source-release.mjs';
 
@@ -65,6 +77,43 @@ describe('ASF source release verification', () => {
     );
   });
 
+  test('requires one current valid GPG signature status', () => {
+    const fingerprint = 'A'.repeat(40);
+    assert.deepEqual(
+      validateGpgVerificationStatus(
+        `[GNUPG:] GOODSIG ABCDEF0123456789 Release Test\n[GNUPG:] VALIDSIG ${fingerprint} 2026-08-20 1787193600 0 4 0 1 10 00 ${fingerprint}\n`,
+      ),
+      { fingerprint, primaryFingerprint: fingerprint },
+    );
+    for (const status of ['EXPKEYSIG', 'EXPSIG', 'KEYEXPIRED', 'KEYREVOKED', 'REVKEYSIG']) {
+      assert.throws(
+        () =>
+          validateGpgVerificationStatus(
+            `[GNUPG:] ${status} ABCDEF0123456789 Release Test\n[GNUPG:] GOODSIG ABCDEF0123456789 Release Test\n[GNUPG:] VALIDSIG ${fingerprint} 2026-08-20 1787193600 0 4 0 1 10 00 ${fingerprint}\n`,
+          ),
+        new RegExp(status),
+      );
+    }
+  });
+
+  test('requires package and lockfile versions to share one identity', () => {
+    const packageJson = { version: '0.1.12' };
+    const packageLock = { packages: { '': { version: '0.1.12' } }, version: '0.1.12' };
+    assert.doesNotThrow(() =>
+      validatePackageVersions({ packageJson, packageLock, source: 'fixture', version: '0.1.12' }),
+    );
+    assert.throws(
+      () =>
+        validatePackageVersions({
+          packageJson,
+          packageLock: { ...packageLock, version: '9.9.9' },
+          source: 'fixture',
+          version: '0.1.12',
+        }),
+      /package-lock\.json.*9\.9\.9/,
+    );
+  });
+
   test('creates reproducible candidates from committed files only', async () => {
     const temporaryRoot = mkdtempSync(join(tmpdir(), 'maka-asf-source-test-'));
     const repositoryRoot = join(temporaryRoot, 'repository');
@@ -138,10 +187,73 @@ describe('ASF source release verification', () => {
       assert.deepEqual(readFileSync(first.checksumPath), readFileSync(second.checksumPath));
       await assert.doesNotReject(() => verifySourceCandidate({ archivePath: first.archivePath }));
 
-      const entries = execFileSync('tar', ['-tzf', first.archivePath], { encoding: 'utf8' });
+      const entries = execFileSync('tar', ['-tzf', basename(first.archivePath)], {
+        cwd: dirname(first.archivePath),
+        encoding: 'utf8',
+      });
       assert.doesNotMatch(entries, /untracked\.txt/);
       assert.doesNotMatch(entries, /\.claude|\.maka-shots|maka-proposal-zh-review/);
 
+      const gpgHome = join(temporaryRoot, 'gnupg');
+      const keysPath = join(temporaryRoot, 'KEYS');
+      mkdirSync(gpgHome, { mode: 0o700 });
+      chmodSync(gpgHome, 0o700);
+      execFileSync(
+        'gpg',
+        [
+          '--batch',
+          '--homedir',
+          gpgHome,
+          '--pinentry-mode',
+          'loopback',
+          '--passphrase',
+          '',
+          '--quick-generate-key',
+          'ASF Release Test <re...@example.invalid>',
+          'rsa2048',
+          'sign',
+          '1d',
+        ],
+        { stdio: 'ignore' },
+      );
+      const fingerprint = execFileSync(
+        'gpg',
+        ['--batch', '--homedir', gpgHome, '--with-colons', '--fingerprint', '--list-secret-keys'],
+        { encoding: 'utf8' },
+      )
+        .split(/\r?\n/)
+        .find((line) => line.startsWith('fpr:'))
+        ?.split(':')[9];
+      assert.match(fingerprint, /^[0-9A-F]{40}$/);
+      await assert.rejects(
+        () =>
+          signSourceCandidate({
+            archivePath: first.archivePath,
+            gpgHome,
+            keyFingerprint: fingerprint.slice(-16),
+            repositoryRoot,
+            revision: first.commit,
+          }),
+        /complete hexadecimal PGP key fingerprint/,
+      );
+      await assert.doesNotReject(() =>
+        signSourceCandidate({
+          archivePath: first.archivePath,
+          gpgHome,
+          keyFingerprint: fingerprint,
+          repositoryRoot,
+          revision: first.commit,
+        }),
+      );
+      execFileSync(
+        'gpg',
+        ['--batch', '--homedir', gpgHome, '--armor', '--output', keysPath, '--export', fingerprint],
+        { stdio: 'ignore' },
+      );
+      await assert.doesNotReject(() =>
+        verifySourceCandidate({ archivePath: first.archivePath, keysPath }),
+      );
+
       symlinkSync(repositoryRoot, repositoryLink, 'dir');
       const linkedScript = join(repositoryLink, 'scripts/asf-source-release.mjs');
       mkdirSync(join(repositoryRoot, 'scripts'));
@@ -159,6 +271,15 @@ describe('ASF source release verification', () => {
         '-m',
         'add release script',
       ]);
+      await assert.rejects(
+        () =>
+          reproduceSourceCandidate({
+            archivePath: first.archivePath,
+            repositoryRoot,
+            revision: 'HEAD',
+          }),
+        /Candidate bytes do not match/,
+      );
       execFileSync(
         process.execPath,
         [
@@ -178,6 +299,32 @@ describe('ASF source release verification', () => {
           archivePath: join(linkedOutput, 'apache-maka-0.1.12-incubating-src.tar.gz'),
         }),
       );
+
+      const mismatchedLock = JSON.parse(readFileSync(join(repositoryRoot, 'package-lock.json')));
+      mismatchedLock.version = '9.9.9';
+      writeFileSync(
+        join(repositoryRoot, 'package-lock.json'),
+        `${JSON.stringify(mismatchedLock, null, 2)}\n`,
+      );
+      git(repositoryRoot, ['add', 'package-lock.json']);
+      git(repositoryRoot, [
+        '-c',
+        'user.name=ASF Release Test',
+        '-c',
+        'user.email=release-test@example.invalid',
+        'commit',
+        '-m',
+        'mismatch lock version',
+      ]);
+      await assert.rejects(
+        () =>
+          createSourceCandidate({
+            outputDirectory: join(temporaryRoot, 'mismatched'),
+            repositoryRoot,
+            version: '0.1.12',
+          }),
+        /package-lock\.json.*9\.9\.9/,
+      );
     } finally {
       rmSync(temporaryRoot, { force: true, recursive: true });
     }
diff --git a/scripts/asf-source-workflow-policy.test.mjs b/scripts/asf-source-workflow-policy.test.mjs
new file mode 100644
index 000000000..dfd98fd9c
--- /dev/null
+++ b/scripts/asf-source-workflow-policy.test.mjs
@@ -0,0 +1,42 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { describe, test } from 'node:test';
+import { formatGitHubOutputs, planTests } from './ci-test-plan.mjs';
+
+const workflowPath = join(import.meta.dirname, '../.github/workflows/asf-source-candidate.yml');
+
+describe('ASF source workflow policy', () => {
+  test('binds build, verification, extraction, and upload to one candidate identity', () => {
+    const workflow = readFileSync(workflowPath, 'utf8');
+    assert.match(workflow, /RELEASE_SHA: \$\{\{ github\.sha \}\}/);
+    assert.match(workflow, /ref: \$\{\{ env\.RELEASE_SHA \}\}/);
+    assert.match(workflow, /--revision "\$RELEASE_SHA"/);
+    assert.match(workflow, /--artifact "\$CANDIDATE_PATH"/);
+    assert.match(workflow, /tar -xzf "\$CANDIDATE_PATH"/);
+    assert.match(workflow, /\$\{\{ env\.CANDIDATE_PATH \}\}\.sha512/);
+    assert.match(workflow, /run: npm run check:asf-source/);
+
+    const orderedSteps = [
+      'Create the unsigned source candidate',
+      'Verify archive structure and SHA-512',
+      'Extract the exact candidate',
+      'Test the extracted source',
+      'Upload the verified unsigned candidate',
+    ];
+    let previousIndex = -1;
+    for (const step of orderedSteps) {
+      const index = workflow.indexOf(step);
+      assert.ok(index > previousIndex, `${step} must preserve the candidate gate order`);
+      previousIndex = index;
+    }
+  });
+
+  test('routes source release authority changes to the dedicated required lane', () => {
+    const graph = { dependents: new Map(), dirs: [], testDirs: new Set() };
+    const selected = planTests(['scripts/asf-source-release.mjs'], { graph });
+    assert.equal(selected.asfSource, true);
+    assert.match(formatGitHubOutputs(selected), /^asf_source=true$/m);
+    assert.equal(planTests(['scripts/audit-alignment.mjs'], { graph }).asfSource, false);
+  });
+});
diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs
index 3d67121b5..e8bf197ca 100644
--- a/scripts/ci-test-plan.mjs
+++ b/scripts/ci-test-plan.mjs
@@ -24,6 +24,21 @@ const TYPECHECK_ONLY_FILES = new Set([
   'tsconfig.lib.json',
 ]);
 
+const ASF_SOURCE_FILES = new Set([
+  '.gitattributes',
+  '.github/ASF_SOURCE_RELEASE.md',
+  '.github/workflows/asf-source-candidate.yml',
+  'DISCLAIMER-WIP',
+  'LICENSE',
+  'NOTICE',
+  'package-lock.json',
+  'package.json',
+  'scripts/asf-source-release.mjs',
+  'scripts/asf-source-release.test.mjs',
+  'scripts/asf-source-workflow-policy.test.mjs',
+  'scripts/ci-test-plan.mjs',
+]);
+
 const WINDOWS_BASELINE_FILES = new Set([
   '.github/workflows/windows-baseline.yml',
   'scripts/windows-process-identity.ps1',
@@ -229,6 +244,7 @@ export function planTests(changedFiles, options = {}) {
   if (full) {
     const workspaces = [...graph.dirs];
     return {
+      asfSource: true,
       astryxSurface: true,
       code: true,
       e2e: true,
@@ -300,6 +316,7 @@ export function planTests(changedFiles, options = {}) {
     windowsBaselineChanged || files.some((path) => isWindowsStorageSensitivePath(path));
 
   return {
+    asfSource: files.some((path) => ASF_SOURCE_FILES.has(path)),
     astryxSurface: files.some((path) => isAstryxSurfaceInventoryPath(path)),
     code,
     // Electron E2E + alignment audit (same job). Product desktop/ui sources and
@@ -327,6 +344,7 @@ export function planTests(changedFiles, options = {}) {
 
 export function formatGitHubOutputs(plan) {
   return [
+    `asf_source=${plan.asfSource}`,
     `astryx_surface=${plan.astryxSurface}`,
     `code=${plan.code}`,
     `e2e=${plan.e2e}`,


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