diff --git a/.github/workflows/release-swift-cli.yml b/.github/workflows/release-swift-cli.yml index 1f0c39a..abeb6c1 100644 --- a/.github/workflows/release-swift-cli.yml +++ b/.github/workflows/release-swift-cli.yml @@ -742,6 +742,7 @@ jobs: PREPARE_SCRIPT: ${{ inputs.prepare-script }} run: | set -euo pipefail + git config --global --add safe.directory "$GITHUB_WORKSPACE" apt-get update apt-get install -y --no-install-recommends python3 file if [[ -n "$GENERATE_VERSION_SCRIPT" ]]; then "$GENERATE_VERSION_SCRIPT"; fi diff --git a/CHANGELOG.md b/CHANGELOG.md index be305f5..b6fa801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +## 1.11.8 - 2026-10-18 + +- Trust only the exact Swift CLI Linux container checkout before dependency preparation so Git ownership checks do not block the release lockfile check. + ## 1.11.7 - 2026-10-18 - Move all macOS release builds, signing, CI, and independent verification to the GA `xcode-27` runners; verify x86_64 with Rosetta, preserve caller deployment targets and legacy v1 runner inputs, and keep Sparkle's three attestations distinct by verifier identity. diff --git a/README.md b/README.md index 0210219..3460198 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ release-build proof. Set `dry_run: true` to rehearse the exact current protected main commit, even when the requested version already has a historical tag. Validation, signing, notarization, and both write-free verifiers run from retained Actions artifacts; tag creation, draft creation, publication, Homebrew handoff, and closeout are skipped. Apple still receives the candidate ZIP for notarization. Existing callers retain `dry_run: false`; dispatch callers should default their user-facing input to true. +The Linux container explicitly trusts only its exact `GITHUB_WORKSPACE` checkout for Git operations before dependency preparation. The `Package.resolved` drift check remains required; unrelated repositories are not trusted. + `include-helper: false` removes the companion dylib from build requirements, signing, the signing manifest, and both verifiers. `include-linux: false` skips the Linux build/download and omits its archive from the inventory and checksums. Both default to true for existing imsg callers. Set `require-resource-bundles: false` for CLIs without SwiftPM resources. Disabled helper/Linux names and identifiers may be empty. `macos-runner` and `verify-arm64-runner` default to `xcode-27`; `xcode-27-xlarge` is available for callers that need larger runners. Existing v1 macOS labels remain accepted as aliases for `xcode-27`. `minimum-macos-version` defaults to 14 and sets `MACOSX_DEPLOYMENT_TARGET` for the build; keep Swift package platforms and any explicit build-script targets aligned with it. Both verifiers run on macOS 27: arm64 natively and x86_64 explicitly under Rosetta. This proves execution on macOS 27, not runtime compatibility on the minimum supported OS. Empty `prepare-script`, `generate-version-script`, and `entitlements-file` disable those optional steps. An empty `version-file` requires `metadata-check-script`, a tracked credential-free executable called with `vX.Y.Z` during Linux validation; it must validate the caller's version metadata. `macos-build-script` must emit an executable into `OUTPUT_DIR`; a thin adapter may call an existing local universal builder. diff --git a/scripts/test-swift-linux-git-ownership.mjs b/scripts/test-swift-linux-git-ownership.mjs new file mode 100644 index 0000000..d0f261f --- /dev/null +++ b/scripts/test-swift-linux-git-ownership.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadWorkflow, workflowStep } from './workflow-source.cjs'; + +const workflow = loadWorkflow('release-swift-cli.yml'); +const prepare = workflowStep(workflow, 'build-linux', 'name', 'Resolve and patch SwiftPM dependencies').run; +const root = realpathSync(mkdtempSync(join(tmpdir(), 'swift-git-owner-'))); +try { + const workspace = join(root, 'checkout with spaces'); + const unrelated = join(root, 'unrelated'); + const home = join(root, 'home'); + const tools = join(root, 'tools'); + for (const dir of [workspace, unrelated, home, tools]) mkdirSync(dir); + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_'))); + Object.assign(env, { + HOME: home, XDG_CONFIG_HOME: home, GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: join(home, '.gitconfig'), GITHUB_WORKSPACE: workspace, + PATH: `${tools}:${process.env.PATH}`, + }); + const run = (command, args, cwd = workspace, runEnv = env) => + spawnSync(command, args, { cwd, env: runEnv, encoding: 'utf8' }); + const success = result => assert.equal(result.status, 0, result.stdout + result.stderr); + for (const cwd of [workspace, unrelated]) { + success(run('git', ['init', '-q'], cwd)); + writeFileSync(join(cwd, 'Package.resolved'), '{"pins":[]}\n'); + success(run('git', ['add', 'Package.resolved'], cwd)); + success(run('git', ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', + '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture'], cwd)); + } + env.GIT_TEST_ASSUME_DIFFERENT_OWNER = '1'; + const before = run('git', ['diff', '--exit-code', '--', 'Package.resolved']); + assert.equal(before.status, 129, 'fixture must reproduce the failing release diff'); + assert.match(run('git', ['status', '--short']).stderr, /dubious ownership/); + + // Execute the production shell without installing packages or resolving real dependencies. + for (const tool of ['apt-get', 'swift']) { + writeFileSync(join(tools, tool), '#!/bin/bash\nexit 0\n', { mode: 0o755 }); + } + const caller = join(tools, 'caller'); + writeFileSync(caller, '#!/bin/bash\nset -eu\ngit diff --exit-code -- Package.resolved\n', { mode: 0o755 }); + env.GENERATE_VERSION_SCRIPT = caller; + env.PREPARE_SCRIPT = caller; + success(run('bash', ['-c', prepare])); + assert.deepEqual(run('git', ['config', '--global', '--get-all', 'safe.directory']).stdout.trim().split('\n'), + [workspace], 'trust must name only the exact checkout'); + success(run('git', ['diff', '--exit-code', '--', 'Package.resolved'])); + writeFileSync(join(workspace, 'Package.resolved'), '{"pins":["changed"]}\n'); + assert.equal(run('git', ['diff', '--exit-code', '--', 'Package.resolved']).status, 1, + 'lockfile drift must still fail'); + const other = run('git', ['diff', '--exit-code', '--', 'Package.resolved'], unrelated); + assert.notEqual(other.status, 0, 'unrelated checkouts must remain untrusted'); + assert.match(run('git', ['status', '--short'], unrelated).stderr, /dubious ownership/); +} finally { + rmSync(root, { recursive: true, force: true }); +} +console.log('Swift Linux checkout ownership regression passed'); diff --git a/scripts/validate-workflows.sh b/scripts/validate-workflows.sh index d879ec2..7eff4a3 100755 --- a/scripts/validate-workflows.sh +++ b/scripts/validate-workflows.sh @@ -29,6 +29,7 @@ node scripts/test-checksum-collisions.mjs node scripts/test-build-artifact-matrix.mjs node scripts/test-split-host-build.mjs node scripts/test-swift-cli-workflow.mjs +node scripts/test-swift-linux-git-ownership.mjs node scripts/test-swift-cli-options.mjs node scripts/test-swift-keychain-lifecycle.mjs node scripts/test-swift-draft-isolation.mjs