diff --git a/.github/workflows/check-action-file.yaml b/.github/workflows/check-action-file.yaml index b9626bc..06d6d50 100644 --- a/.github/workflows/check-action-file.yaml +++ b/.github/workflows/check-action-file.yaml @@ -9,6 +9,8 @@ on: - '.github/workflows/*.yaml' - '.github/workflows/*.yml' - '.github/actionlint.yaml' + - 'actions/resolve-sca-license-scope/**' + - 'actions/restore-sca-analysis-cache/**' - 'actions/upload-artifact-with-retry/action.yaml' - 'actions/upload-artifact-with-retry/state-machine.schema.json' - 'scripts/select_coverage_artifacts.py' @@ -61,6 +63,19 @@ jobs: run: python3 scripts/test_select_coverage_artifacts.py -v - name: Test TKE merge subject identity contract run: python3 scripts/test_merge_trigger_tke_subject.py -v + - name: Test SCA license scope selection + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { execFileSync } = require('node:child_process'); + execFileSync(process.execPath, [ + '--test', + `${process.env.GITHUB_WORKSPACE}/actions/resolve-sca-license-scope/scope.test.js`, + ], { stdio: 'inherit' }); + - name: Test SCA analysis cache contracts + run: | + bash actions/restore-sca-analysis-cache/cache-state.test.sh + bash actions/restore-sca-analysis-cache/environment-hash.test.sh - name: Guard deliberate workflow disablements env: ACTIONLINT: ${{ steps.install-actionlint.outputs.executable }} @@ -115,11 +130,14 @@ jobs: awk -F '\t' '$4 == "if-cond" { print $1 ":" $2 ":" $3 ": " $5 }' exit 1 fi - - name: Validate artifact retry action metadata + - name: Validate action metadata uses: dsanders11/json-schema-validate-action@eddf079f55830cc9a916a3c512ba9086240d2fea # v2.0.0 with: schema: https://json.schemastore.org/github-action.json - files: actions/upload-artifact-with-retry/action.yaml + files: | + actions/resolve-sca-license-scope/action.yaml + actions/restore-sca-analysis-cache/action.yaml + actions/upload-artifact-with-retry/action.yaml - name: Validate artifact retry state machine uses: dsanders11/json-schema-validate-action@eddf079f55830cc9a916a3c512ba9086240d2fea # v2.0.0 with: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cf398ef..92639f8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -395,6 +395,7 @@ jobs: # the smaller standard ARM runner. A repository variable remains an # explicit escape hatch for a larger runner. runs-on: ${{ vars.SCA_RUNNER_LABEL || 'arm64-mo-shanghai-4c8g' }} + timeout-minutes: 90 name: SCA Test on Linux/arm64 steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -424,7 +425,7 @@ jobs: run: | set -euo pipefail make clean - rm -rf .sca-static-check-tools + rm -rf .sca-static-check-tools .sca-golangci-analysis-cache .sca-onnx-runtime - name: Resolve Go module cache metadata id: go-module-cache-metadata run: | @@ -444,18 +445,37 @@ jobs: key: matrixone-go-mod-${{ runner.os }}-${{ runner.arch }}-${{ steps.go-module-cache-metadata.outputs.version }}-${{ hashFiles('go.mod', 'go.sum') }} restore-keys: | matrixone-go-mod-${{ runner.os }}-${{ runner.arch }}-${{ steps.go-module-cache-metadata.outputs.version }}- - - name: Move Go module cache out of the source tree + - name: Resolve Go module cache runtime layout + id: go-module-cache-layout run: | set -euo pipefail + # golangci-lint includes absolute compiled-file paths in its package + # action IDs. The optimized MatrixOne contract explicitly excludes + # this stable repository-local module cache from license scans. + if grep -q '^static-check-analysis:' Makefile && \ + grep -Fqx " - '.sca-go-module-cache/'" .licenserc.yml; then + test "$(go env GOMODCACHE)" = "$GITHUB_WORKSPACE/.sca-go-module-cache" + echo "analysis-compatible=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Preserve the original out-of-tree behavior for MatrixOne branches + # that predate the split SCA/cache contract. Their license config + # does not ignore the module cache, so leaving it in the checkout + # would make a complete header scan traverse third-party modules. staging_dir="$GOMODCACHE" runtime_root=$(mktemp -d "${RUNNER_TEMP%/}/matrixone-go-mod.XXXXXX") runtime_dir="$runtime_root/cache" - if [ -d "$staging_dir" ]; then + if [[ -d "$staging_dir" ]]; then mv "$staging_dir" "$runtime_dir" else mkdir "$runtime_dir" fi echo "GOMODCACHE=$runtime_dir" >> "$GITHUB_ENV" + echo "analysis-compatible=false" >> "$GITHUB_OUTPUT" + - name: Restore golangci-lint analysis cache + if: steps.go-module-cache-layout.outputs.analysis-compatible == 'true' + uses: matrixorigin/CI/actions/restore-sca-analysis-cache@main # Keep these metadata outputs and the key schema aligned with # matrixorigin/matrixone:.github/workflows/sca-go-module-cache.yaml. - name: Resolve native cache metadata @@ -478,6 +498,49 @@ jobs: cgo/libmo.a cgo/libmo.so key: matrixone-sca-native-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.native-cache-metadata.outputs.compiler }}-${{ steps.native-cache-metadata.outputs.cxx-compiler }}-${{ steps.native-cache-metadata.outputs.cmake }}-${{ hashFiles('Makefile', 'cgo/*.c', 'cgo/*.h', 'cgo/Makefile', 'thirdparties/Makefile', 'thirdparties/*.tar.gz', 'thirdparties/download') }} + - name: Validate restored native SCA prerequisites + id: native-cache-state + run: | + set -euo pipefail + usable=false + if [[ "${{ steps.native-cache.outputs.cache-hit }}" == "true" ]] && \ + [[ -f thirdparties/install/include/xxhash.h ]] && \ + [[ -f thirdparties/install/include/usearch.h ]] && \ + [[ -f thirdparties/install/lib/libroaring.a ]] && \ + [[ -f thirdparties/install/lib/libusearch_c.a ]] && \ + [[ -n "$(find thirdparties/install/lib -maxdepth 1 -type f -name 'onnxruntime*.so' -print -quit)" ]] && \ + [[ -f cgo/libmo.a ]] && \ + [[ -f cgo/libmo.so ]]; then + usable=true + elif [[ "${{ steps.native-cache.outputs.cache-hit }}" == "true" ]]; then + echo "::warning::The exact native SCA cache is incomplete; rebuilding from verified inputs" + fi + echo "usable=$usable" >> "$GITHUB_OUTPUT" + - name: Restore ONNX Runtime fallback + id: onnx-cache + if: steps.native-cache-state.outputs.usable != 'true' + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: .sca-onnx-runtime + key: matrixone-sca-onnx-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('thirdparties/Makefile', 'thirdparties/download') }} + - name: Validate restored ONNX Runtime fallback + id: onnx-cache-state + if: steps.native-cache-state.outputs.usable != 'true' + run: | + set -euo pipefail + usable=false + if [[ "${{ steps.onnx-cache.outputs.cache-hit }}" == "true" ]]; then + mapfile -t onnx_files < <(find .sca-onnx-runtime -maxdepth 1 -type f -name 'onnxruntime*.so' -print) + if [[ "${#onnx_files[@]}" -eq 1 ]] && \ + [[ -f .sca-onnx-runtime/SHA256SUMS ]] && \ + (cd .sca-onnx-runtime && sha256sum --check --strict SHA256SUMS); then + usable=true + else + echo "::warning::The ONNX Runtime fallback cache is incomplete or corrupt; using the normal verified download path" + rm -rf .sca-onnx-runtime + fi + fi + echo "usable=$usable" >> "$GITHUB_OUTPUT" - name: Resolve static-check tool cache metadata id: static-check-tool-cache-metadata run: | @@ -495,24 +558,31 @@ jobs: run: | set -euo pipefail cd "$GITHUB_WORKSPACE" - make config - if [[ "${{ steps.native-cache.outputs.cache-hit }}" == "true" ]] && \ - [[ -f thirdparties/install/include/xxhash.h ]] && \ - [[ -f thirdparties/install/include/usearch.h ]] && \ - [[ -f thirdparties/install/lib/libroaring.a ]] && \ - [[ -f thirdparties/install/lib/libusearch_c.a ]] && \ - [[ -n "$(find thirdparties/install/lib -maxdepth 1 -type f -name 'onnxruntime*.so' -print -quit)" ]] && \ - [[ -f cgo/libmo.a ]] && \ - [[ -f cgo/libmo.so ]]; then - rm -rf "$GITHUB_WORKSPACE/lib" - cp -r thirdparties/install/lib "$GITHUB_WORKSPACE/lib" + # The final static-check target owns the single full package-graph + # verification. Running config here as a separate make invocation + # duplicated `go list -test ./...` on every successful SCA job. + if [[ "${{ steps.native-cache-state.outputs.usable }}" == "true" ]]; then + echo "Using verified native SCA cache" else - make thirdparties - test -f thirdparties/install/include/xxhash.h - test -f thirdparties/install/include/usearch.h - make cgo - test -f cgo/libmo.so + # Top-level cgo owns one complete native generation. Running + # thirdparties separately is both redundant and unsafe: cgo can + # discard that unstamped generation and download ONNX again. + if [[ "${{ steps.onnx-cache-state.outputs.usable }}" == "true" ]]; then + make ONNX_PREBUILT_DIR="$GITHUB_WORKSPACE/.sca-onnx-runtime" cgo + else + make cgo + fi fi + test -f thirdparties/install/include/xxhash.h + test -f thirdparties/install/include/usearch.h + test -f thirdparties/install/lib/libroaring.a + test -f thirdparties/install/lib/libusearch_c.a + find thirdparties/install/lib -maxdepth 1 -type f -name 'onnxruntime*.so' -print -quit | grep -q . + test -f cgo/libmo.a + test -f cgo/libmo.so + rm -rf "$GITHUB_WORKSPACE/lib" + cp -r thirdparties/install/lib "$GITHUB_WORKSPACE/lib" + rm -rf .sca-onnx-runtime - name: Prepare static-check tools run: | set -euo pipefail @@ -544,7 +614,63 @@ jobs: test -x "$runtime_dir/molint" test -x "$runtime_dir/license-eye" echo "$runtime_dir" >> "$GITHUB_PATH" + - name: Resolve PR license scope + id: license-scope + continue-on-error: true + uses: matrixorigin/CI/actions/resolve-sca-license-scope@main + with: + github-token: ${{ secrets.TOKEN_ACTION || github.token }} - name: Static Code Analysis run: | - cd $GITHUB_WORKSPACE - make static-check + set -euo pipefail + cd "$GITHUB_WORKSPACE" + # Keep the legacy path byte-for-byte equivalent during the two-PR + # rollout. The optimized path is enabled only after MatrixOne adds + # the companion target. + if ! grep -q '^static-check-analysis:' Makefile; then + echo "Incremental license target is unavailable; using complete static-check" + make static-check + exit 0 + fi + + # API/rate-limit/transient failures above must conservatively become + # the original complete checks, never a false green or a red gate. + license_header_full="${SCA_LICENSE_HEADER_FULL:-true}" + license_dependency_check="${SCA_LICENSE_DEPENDENCY_CHECK:-true}" + license_paths_file="${SCA_LICENSE_PATHS_FILE:-}" + if [[ "$license_header_full" != "true" ]] && \ + [[ ! -f "$license_paths_file" ]]; then + echo "PR license scope is unavailable; using complete header check" + license_header_full="true" + fi + expected_config_hash="96ac6e17a14a0a93177a965dd842f3311fd4a62a0a2d4efa252c6bb78281e9f7" + expected_tool_recipe_hash="16f233e8b83183889fa284d3b354fbf08c2d2b3630e87f93c7c0e9a67d1548f1" + config_hash="$(sha256sum .licenserc.yml | awk '{print $1}')" + tool_recipe_hash="$(sed -n '/^install-static-check-tools:/,/^$/p' Makefile | sha256sum | awk '{print $1}')" + if [[ "$config_hash" != "$expected_config_hash" ]] || \ + [[ "$tool_recipe_hash" != "$expected_tool_recipe_hash" ]]; then + echo "License configuration or tool recipe is newer than the validated incremental contract; using complete license checks" + license_header_full="true" + license_dependency_check="true" + fi + + if [[ "$license_header_full" == "true" ]]; then + license-eye -c .licenserc.yml header check + else + mapfile -d '' -t license_paths < "$license_paths_file" + if (( ${#license_paths[@]} > 0 )); then + license-eye -c .licenserc.yml header check -- "${license_paths[@]}" + else + echo "No added, copied, modified, or renamed files require a license header check" + fi + fi + + if [[ "$license_dependency_check" == "true" ]]; then + license-eye -c .licenserc.yml dep check + else + echo "Dependency manifests and license rules are unchanged" + fi + + # Run cheap policy checks before the long analyzers so an invalid PR + # fails in seconds rather than consuming the full SCA critical path. + make static-check-analysis GOLANGCI_LINT_CONCURRENCY=1 diff --git a/actions/resolve-sca-license-scope/action.yaml b/actions/resolve-sca-license-scope/action.yaml new file mode 100644 index 0000000..a04bd7f --- /dev/null +++ b/actions/resolve-sca-license-scope/action.yaml @@ -0,0 +1,18 @@ +name: Resolve MatrixOne SCA license scope +description: Select changed license inputs and conservatively fall back to complete checks + +inputs: + github-token: + description: Token used to list pull-request files + required: true + +runs: + using: composite + steps: + - name: Resolve pull-request files + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + github-token: ${{ inputs.github-token }} + script: | + const scope = require('${{ github.action_path }}/scope.js'); + await scope.run({ github, context, core }); diff --git a/actions/resolve-sca-license-scope/scope.js b/actions/resolve-sca-license-scope/scope.js new file mode 100644 index 0000000..9d2aab2 --- /dev/null +++ b/actions/resolve-sca-license-scope/scope.js @@ -0,0 +1,80 @@ +// Copyright 2026 Matrix Origin +// +// Licensed 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 +// +// http://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. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const sourcePattern = /\.(?:go|c|s|h|cpp|proto)$/; +const globPattern = /[*?[\]{}\\]/; +const maxPullFiles = 3000; +const headerRuleInputs = new Set(['.gitignore', '.licenserc.yml', 'Makefile']); +const dependencyInputs = new Set(['.licenserc.yml', 'Makefile', 'go.mod', 'go.sum']); + +function select(files, changedFiles) { + const paths = files + .filter((file) => file.status !== 'removed') + .map((file) => file.filename) + // Command-line paths replace, rather than intersect with, the configured + // header paths. Keep this extension set aligned with MatrixOne's + // .licenserc.yml; a config change forces a complete check below. + .filter((file) => sourcePattern.test(file)); + const names = new Set(files.flatMap((file) => + [file.filename, file.previous_filename].filter(Boolean))); + // GitHub caps this endpoint at 3000 files. Treat the ceiling itself as + // incomplete because a capped changed-files count cannot prove completeness. + const incomplete = files.length !== changedFiles || files.length >= maxPullFiles; + // license-eye treats path arguments as globs. A legal filename containing + // glob metacharacters must never be interpreted as a different file set. + const hasGlobPath = paths.some((file) => globPattern.test(file)); + const headerRulesChanged = [...headerRuleInputs].some((file) => names.has(file)); + const dependenciesChanged = [...dependencyInputs].some((file) => names.has(file)); + const forceFull = incomplete || hasGlobPath; + + return { + paths, + headerFull: forceFull || headerRulesChanged, + dependencyCheck: forceFull || dependenciesChanged, + }; +} + +function publish(core, output, result) { + fs.writeFileSync(output, result.paths.length ? `${result.paths.join('\0')}\0` : ''); + core.exportVariable('SCA_LICENSE_PATHS_FILE', output); + core.exportVariable('SCA_LICENSE_HEADER_FULL', String(result.headerFull)); + core.exportVariable('SCA_LICENSE_DEPENDENCY_CHECK', String(result.dependencyCheck)); +} + +async function run({ github, context, core, runnerTemp = process.env.RUNNER_TEMP }) { + const output = path.join(runnerTemp, 'matrixone-sca-license-paths'); + const pull = context.payload.pull_request; + if (!pull) { + publish(core, output, { paths: [], headerFull: true, dependencyCheck: true }); + core.info('Non-PR event: use complete license checks'); + return; + } + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pull.number, + per_page: 100, + }); + const result = select(files, pull.changed_files); + publish(core, output, result); + core.info(`PR files=${files.length}/${pull.changed_files}, header paths=${result.paths.length}, full=${result.headerFull}, dependency=${result.dependencyCheck}`); +} + +module.exports = { run, select }; diff --git a/actions/resolve-sca-license-scope/scope.test.js b/actions/resolve-sca-license-scope/scope.test.js new file mode 100644 index 0000000..d59c9b8 --- /dev/null +++ b/actions/resolve-sca-license-scope/scope.test.js @@ -0,0 +1,159 @@ +// Copyright 2026 Matrix Origin +// +// Licensed 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 +// +// http://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. + +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { run, select } = require('./scope'); + +test('checks an ordinary changed source file only', () => { + assert.deepEqual(select([ + { status: 'modified', filename: 'pkg/example/a.go' }, + { status: 'modified', filename: 'README.md' }, + ], 2), { + paths: ['pkg/example/a.go'], + headerFull: false, + dependencyCheck: false, + }); +}); + +test('does not check deleted source files', () => { + assert.deepEqual(select([ + { status: 'removed', filename: 'pkg/example/a.go' }, + ], 1), { paths: [], headerFull: false, dependencyCheck: false }); +}); + +test('checks the destination of a source rename', () => { + assert.deepEqual(select([ + { status: 'renamed', filename: 'pkg/new.go', previous_filename: 'pkg/old.go' }, + ], 1), { + paths: ['pkg/new.go'], + headerFull: false, + dependencyCheck: false, + }); +}); + +test('a renamed policy input forces both complete checks', () => { + assert.deepEqual(select([ + { status: 'renamed', filename: 'license.yml', previous_filename: '.licenserc.yml' }, + ], 1), { paths: [], headerFull: true, dependencyCheck: true }); +}); + +test('an incomplete API result forces both complete checks', () => { + assert.deepEqual(select([ + { status: 'modified', filename: 'README.md' }, + ], 2), { paths: [], headerFull: true, dependencyCheck: true }); +}); + +test('the pull-files API ceiling forces both complete checks', () => { + const files = Array.from({ length: 3000 }, (_, index) => ({ + status: 'modified', + filename: `docs/${index}.md`, + })); + assert.deepEqual(select(files, 3000), { + paths: [], + headerFull: true, + dependencyCheck: true, + }); +}); + +test('a glob-like legal filename forces both complete checks', () => { + assert.deepEqual(select([ + { status: 'added', filename: 'pkg/a[1].go' }, + ], 1), { paths: ['pkg/a[1].go'], headerFull: true, dependencyCheck: true }); +}); + +test('dependency inputs do not force an unrelated full header scan', () => { + assert.deepEqual(select([ + { status: 'modified', filename: 'go.mod' }, + ], 1), { paths: [], headerFull: false, dependencyCheck: true }); +}); + +test('run publishes a NUL-delimited path file and conservative flags', async (t) => { + const runnerTemp = fs.mkdtempSync(path.join(os.tmpdir(), 'sca-license-scope-')); + t.after(() => fs.rmSync(runnerTemp, { recursive: true, force: true })); + const exported = new Map(); + await run({ + runnerTemp, + context: { + repo: { owner: 'matrixorigin', repo: 'matrixone' }, + payload: { pull_request: { number: 42, changed_files: 2 } }, + }, + github: { + paginate: async (_method, request) => { + assert.equal(request.pull_number, 42); + return [ + { status: 'modified', filename: 'pkg/a.go' }, + { status: 'modified', filename: 'go.mod' }, + ]; + }, + rest: { pulls: { listFiles: Symbol('listFiles') } }, + }, + core: { + exportVariable: (name, value) => exported.set(name, value), + info: () => {}, + }, + }); + + assert.equal( + fs.readFileSync(exported.get('SCA_LICENSE_PATHS_FILE'), 'utf8'), + 'pkg/a.go\0', + ); + assert.equal(exported.get('SCA_LICENSE_HEADER_FULL'), 'false'); + assert.equal(exported.get('SCA_LICENSE_DEPENDENCY_CHECK'), 'true'); +}); + +test('non-PR events publish complete-check defaults', async (t) => { + const runnerTemp = fs.mkdtempSync(path.join(os.tmpdir(), 'sca-license-scope-')); + t.after(() => fs.rmSync(runnerTemp, { recursive: true, force: true })); + const exported = new Map(); + await run({ + runnerTemp, + context: { payload: {} }, + github: {}, + core: { + exportVariable: (name, value) => exported.set(name, value), + info: () => {}, + }, + }); + + assert.equal(exported.get('SCA_LICENSE_HEADER_FULL'), 'true'); + assert.equal(exported.get('SCA_LICENSE_DEPENDENCY_CHECK'), 'true'); + assert.equal(fs.readFileSync(exported.get('SCA_LICENSE_PATHS_FILE'), 'utf8'), ''); +}); + +test('an API failure propagates before publishing partial state', async () => { + const exported = new Map(); + await assert.rejects(run({ + runnerTemp: os.tmpdir(), + context: { + repo: { owner: 'matrixorigin', repo: 'matrixone' }, + payload: { pull_request: { number: 42, changed_files: 1 } }, + }, + github: { + paginate: async () => { throw new Error('rate limited'); }, + rest: { pulls: { listFiles: Symbol('listFiles') } }, + }, + core: { + exportVariable: (name, value) => exported.set(name, value), + info: () => {}, + }, + }), /rate limited/); + assert.equal(exported.size, 0); +}); diff --git a/actions/restore-sca-analysis-cache/action.yaml b/actions/restore-sca-analysis-cache/action.yaml new file mode 100644 index 0000000..4ef6472 --- /dev/null +++ b/actions/restore-sca-analysis-cache/action.yaml @@ -0,0 +1,130 @@ +name: Restore MatrixOne SCA analysis cache +description: Best-effort restore of the trusted golangci-lint cache for MatrixOne SCA + +outputs: + cache-hit: + description: Whether a usable exact current cache generation was restored + value: ${{ steps.isolate.outputs.cache-hit }} + cache-exact-match: + description: Whether the cache service matched the exact primary key, even if its contents were unusable + value: ${{ steps.isolate.outputs.cache-exact-match }} + cache-primary-key: + description: Primary key to use when a trusted producer saves the cache + value: ${{ steps.metadata.outputs.cache-primary-key }} + cache-matched-key: + description: Cache key selected by exact or prefix restore + value: ${{ steps.analysis-cache.outputs.cache-matched-key }} + cache-usable: + description: Whether the restored archive contained analysis files + value: ${{ steps.isolate.outputs.cache-usable }} + cache-restore-seconds: + description: Wall-clock seconds spent restoring and isolating the analysis cache + value: ${{ steps.isolate.outputs.cache-restore-seconds }} + cache-size-kib: + description: Disk usage in KiB of the usable isolated analysis cache, or zero on a miss + value: ${{ steps.isolate.outputs.cache-size-kib }} + +runs: + using: composite + steps: + - name: Resolve analysis cache metadata + id: metadata + shell: bash + env: + # Golangci-lint salts entries with its binary, relevant configuration, + # go.mod, package source, and dependency hashes. Keep the outer input + # key narrow so unrelated Makefile/native changes cannot create a new + # repository-level cache lineage. + SCA_INPUT_HASH: ${{ hashFiles('go.mod', '.golangci.yml') }} + SCA_RUNNER_OS: ${{ runner.os }} + SCA_RUNNER_ARCH: ${{ runner.arch }} + run: | + set -euo pipefail + go_version="$(go env GOVERSION)" + compiler="$(cc -dumpmachine)-$(cc -dumpfullversion -dumpversion)" + cxx_compiler="$(c++ -dumpmachine)-$(c++ -dumpfullversion -dumpversion)" + cmake_version="$(cmake --version | sed -n '1p')" + tool_recipe="$(sed -n '/^install-static-check-tools:/,/^$/p' Makefile | sha256sum | awk '{print $1}')" + module_root="$(GOWORK=off go list -m -f '{{.Dir}}')" + gomodcache="$(go env GOMODCACHE)" + goroot="$(go env GOROOT)" + gocache="$(go env GOCACHE)" + # golangci-lint hashes absolute source, module, and toolchain file + # paths into its package action IDs. Path-incompatible archives are + # not real cache hits even when GitHub matches the outer key. + environment_hash="$(bash "$GITHUB_ACTION_PATH/environment-hash.sh" \ + "$go_version" "$compiler" "$cxx_compiler" "$cmake_version" \ + "$tool_recipe" "$module_root" "$gomodcache" "$goroot" "$gocache")" + test -n "$environment_hash" + test -n "$SCA_INPUT_HASH" + generation=$(($(date -u +%s) / 604800)) + environment_prefix="matrixone-sca-golangci-v3-${SCA_RUNNER_OS}-${SCA_RUNNER_ARCH}-${environment_hash}-" + generation_prefix="${environment_prefix}${generation}-" + echo "cache-primary-key=${generation_prefix}${SCA_INPUT_HASH}" >> "$GITHUB_OUTPUT" + echo "generation-prefix=$generation_prefix" >> "$GITHUB_OUTPUT" + echo "environment-prefix=$environment_prefix" >> "$GITHUB_OUTPUT" + - name: Prepare analysis cache restore + id: prepare + shell: bash + run: | + set -euo pipefail + rm -rf "$GITHUB_WORKSPACE/.sca-golangci-analysis-cache" + echo "restore-start-epoch=$(date -u +%s)" >> "$GITHUB_OUTPUT" + - name: Restore trusted analysis cache + id: analysis-cache + continue-on-error: true + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: .sca-golangci-analysis-cache + key: ${{ steps.metadata.outputs.cache-primary-key }} + restore-keys: | + ${{ steps.metadata.outputs.generation-prefix }} + ${{ steps.metadata.outputs.environment-prefix }} + - name: Isolate analysis cache from repository scans + id: isolate + shell: bash + env: + CACHE_EXACT_MATCH: ${{ steps.analysis-cache.outputs.cache-hit }} + CACHE_MATCHED_KEY: ${{ steps.analysis-cache.outputs.cache-matched-key }} + CACHE_RESTORE_OUTCOME: ${{ steps.analysis-cache.outcome }} + RESTORE_START_EPOCH: ${{ steps.prepare.outputs.restore-start-epoch }} + run: | + set -euo pipefail + staging_dir="$GITHUB_WORKSPACE/.sca-golangci-analysis-cache" + # An unavailable cache service is an optimization miss, not an SCA + # failure. Never consume a partially downloaded archive. + if [[ "$CACHE_RESTORE_OUTCOME" != "success" ]]; then + rm -rf "$staging_dir" + fi + runtime_root="$(mktemp -d "${RUNNER_TEMP%/}/matrixone-sca-analysis.XXXXXX")" + usable="false" + if [[ -d "$staging_dir/golangci-lint" ]] && \ + [[ -n "$(find "$staging_dir/golangci-lint" -type f -print -quit)" ]]; then + mv "$staging_dir/golangci-lint" "$runtime_root/golangci-lint" + usable="true" + else + mkdir "$runtime_root/golangci-lint" + fi + rm -rf "$staging_dir" + IFS=$'\t' read -r exact_match cache_hit state < <( + bash "$GITHUB_ACTION_PATH/cache-state.sh" \ + "$CACHE_EXACT_MATCH" "$usable" "$CACHE_MATCHED_KEY" + ) + cache_kib="0" + if [[ "$usable" == "true" ]]; then + cache_kib="$(du -sk "$runtime_root/golangci-lint" | awk '{print $1}')" + fi + restore_finished_epoch="$(date -u +%s)" + restore_seconds="0" + if (( restore_finished_epoch >= RESTORE_START_EPOCH )); then + restore_seconds=$((restore_finished_epoch - RESTORE_START_EPOCH)) + fi + echo "cache-hit=$cache_hit" >> "$GITHUB_OUTPUT" + echo "cache-exact-match=$exact_match" >> "$GITHUB_OUTPUT" + echo "cache-usable=$usable" >> "$GITHUB_OUTPUT" + echo "cache-restore-seconds=$restore_seconds" >> "$GITHUB_OUTPUT" + echo "cache-size-kib=$cache_kib" >> "$GITHUB_OUTPUT" + echo "GOLANGCI_LINT_CACHE=$runtime_root/golangci-lint" >> "$GITHUB_ENV" + printf '### golangci-lint analysis cache\n\nState: %s\n\nMatched key: %s\n\nRestored disk size: %s MiB\n\nRestore path: %s seconds\n' \ + "$state" "${CACHE_MATCHED_KEY:-none}" "$((cache_kib / 1024))" "$restore_seconds" \ + >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/restore-sca-analysis-cache/cache-state.sh b/actions/restore-sca-analysis-cache/cache-state.sh new file mode 100644 index 0000000..cf3785d --- /dev/null +++ b/actions/restore-sca-analysis-cache/cache-state.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +# Normalize the cache action's raw output and classify the restored contents. +# Output fields are tab-delimited: exact_match, usable_exact_hit, state. +set -euo pipefail + +raw_exact_match="${1:-}" +usable="${2:-}" +matched_key="${3:-}" + +exact_match="false" +if [[ "$raw_exact_match" == "true" ]]; then + exact_match="true" +fi + +cache_hit="false" +if [[ "$exact_match" == "true" ]] && [[ "$usable" == "true" ]]; then + cache_hit="true" + state="exact hit" +elif [[ "$usable" == "true" ]]; then + state="prefix hit" +elif [[ -n "$matched_key" ]]; then + state="unusable restore" +else + state="miss" +fi + +printf '%s\t%s\t%s\n' "$exact_match" "$cache_hit" "$state" diff --git a/actions/restore-sca-analysis-cache/cache-state.test.sh b/actions/restore-sca-analysis-cache/cache-state.test.sh new file mode 100644 index 0000000..637c424 --- /dev/null +++ b/actions/restore-sca-analysis-cache/cache-state.test.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +assert_state() { + local expected="$1" + shift + local actual + actual="$(bash "$script_dir/cache-state.sh" "$@")" + if [[ "$actual" != "$expected" ]]; then + printf 'expected <%s>, got <%s>\n' "$expected" "$actual" >&2 + return 1 + fi +} + +assert_state $'true\ttrue\texact hit' true true exact-key +assert_state $'false\tfalse\tprefix hit' false true prefix-key +assert_state $'true\tfalse\tunusable restore' true false exact-key +assert_state $'false\tfalse\tunusable restore' false false prefix-key +assert_state $'false\tfalse\tmiss' false false '' + +echo 'cache state tests passed' diff --git a/actions/restore-sca-analysis-cache/environment-hash.sh b/actions/restore-sca-analysis-cache/environment-hash.sh new file mode 100644 index 0000000..bd2d20d --- /dev/null +++ b/actions/restore-sca-analysis-cache/environment-hash.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Build the compatibility identity for an analysis-cache archive. The last +# four inputs are absolute paths embedded by go/packages and golangci-lint in +# package action IDs, so omitting any of them can turn an outer exact hit into +# an internal cache miss. +set -euo pipefail + +if [[ "$#" -ne 9 ]]; then + echo "usage: environment-hash.sh GO_VERSION CC CXX CMAKE TOOL_RECIPE MODULE_ROOT GOMODCACHE GOROOT GOCACHE" >&2 + exit 2 +fi + +# NUL separators make the identity unambiguous even when adjacent values have +# different boundaries but the same concatenation. +printf '%s\0' "$@" | sha256sum | awk '{print $1}' diff --git a/actions/restore-sca-analysis-cache/environment-hash.test.sh b/actions/restore-sca-analysis-cache/environment-hash.test.sh new file mode 100644 index 0000000..a68cd3c --- /dev/null +++ b/actions/restore-sca-analysis-cache/environment-hash.test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "$0")" && pwd)" +hash_script="$script_dir/environment-hash.sh" +base=(go1.26.4 cc-1 cxx-1 cmake-1 tool-1 /work/matrixone /work/matrixone/.sca-go-module-cache /go /go-build) + +base_hash="$(bash "$hash_script" "${base[@]}")" +test -n "$base_hash" +test "$base_hash" = "$(bash "$hash_script" "${base[@]}")" + +# Every compatibility input must own an independent cache lineage, especially +# paths whose absolute spelling is embedded in golangci-lint package hashes. +for index in "${!base[@]}"; do + changed=("${base[@]}") + changed[$index]="${changed[$index]}-changed" + test "$base_hash" != "$(bash "$hash_script" "${changed[@]}")" +done + +left=(a bc c d e f g h i) +right=(ab c c d e f g h i) +test "$(bash "$hash_script" "${left[@]}")" != "$(bash "$hash_script" "${right[@]}")" + +if bash "$hash_script" too few inputs >/dev/null 2>&1; then + echo "environment hash accepted an incomplete compatibility identity" >&2 + exit 1 +fi + +echo "environment hash tests passed"