diff --git a/.github/workflows/ci-extended.yml b/.github/workflows/ci-extended.yml index 84257d768..d44e3910b 100644 --- a/.github/workflows/ci-extended.yml +++ b/.github/workflows/ci-extended.yml @@ -35,12 +35,136 @@ jobs: workflow-lint: name: Workflow Lint runs-on: ubuntu-latest + timeout-minutes: 10 + env: + ACTIONLINT_VERSION: "1.7.12" + ACTIONLINT_ARCHIVE_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + PYFLAKES_VERSION: "3.4.0" + PYFLAKES_WHEEL_SHA256: f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f steps: - name: Checkout uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Install Actionlint toolchain + shell: bash + run: | + set -euo pipefail + + archive_name="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + archive_path="${RUNNER_TEMP}/${archive_name}" + install_dir="${RUNNER_TEMP}/actionlint-${ACTIONLINT_VERSION}" + download_url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive_name}" + + curl \ + --fail \ + --silent \ + --show-error \ + --location \ + --proto '=https' \ + --tlsv1.2 \ + --connect-timeout 15 \ + --max-time 120 \ + --retry 3 \ + --retry-all-errors \ + --retry-max-time 240 \ + --output "${archive_path}" \ + "${download_url}" + bash scripts/ci/verify-sha256.sh "${ACTIONLINT_ARCHIVE_SHA256}" "${archive_path}" + + mkdir -p "${install_dir}" + tar --extract --gzip --file "${archive_path}" --directory "${install_dir}" actionlint + actionlint_bin="${install_dir}/actionlint" + if [ ! -f "${actionlint_bin}" ] || [ -L "${actionlint_bin}" ]; then + echo "::error::Extracted Actionlint must be a regular file and not a symbolic link" + exit 1 + fi + chmod 0755 "${actionlint_bin}" + + version_output="$("${actionlint_bin}" -version)" + actual_version="${version_output%%$'\n'*}" + if [ "${actual_version}" != "${ACTIONLINT_VERSION}" ]; then + echo "::error::Expected Actionlint ${ACTIONLINT_VERSION}, got ${actual_version}" + exit 1 + fi + printf '%s\n' "${version_output}" + + pyflakes_wheel_name="pyflakes-${PYFLAKES_VERSION}-py2.py3-none-any.whl" + pyflakes_wheel_path="${RUNNER_TEMP}/${pyflakes_wheel_name}" + pyflakes_download_url="https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/${pyflakes_wheel_name}" + pyflakes_venv="${RUNNER_TEMP}/pyflakes-${PYFLAKES_VERSION}" + + curl \ + --fail \ + --silent \ + --show-error \ + --location \ + --proto '=https' \ + --tlsv1.2 \ + --connect-timeout 15 \ + --max-time 120 \ + --retry 3 \ + --retry-all-errors \ + --retry-max-time 240 \ + --output "${pyflakes_wheel_path}" \ + "${pyflakes_download_url}" + bash scripts/ci/verify-sha256.sh "${PYFLAKES_WHEEL_SHA256}" "${pyflakes_wheel_path}" + + python3 -m venv "${pyflakes_venv}" + "${pyflakes_venv}/bin/python" -m pip install \ + --disable-pip-version-check \ + --no-deps \ + --no-index \ + "${pyflakes_wheel_path}" + pyflakes_bin="${pyflakes_venv}/bin/pyflakes" + if [ ! -f "${pyflakes_bin}" ] || [ -L "${pyflakes_bin}" ] || [ ! -x "${pyflakes_bin}" ]; then + echo "::error::Installed Pyflakes entry point is not a regular executable file" + exit 1 + fi + pyflakes_version_output="$("${pyflakes_bin}" --version)" + pyflakes_version="${pyflakes_version_output%% *}" + if [ "${pyflakes_version}" != "${PYFLAKES_VERSION}" ]; then + echo "::error::Expected Pyflakes ${PYFLAKES_VERSION}, got ${pyflakes_version}" + exit 1 + fi + printf '%s\n' "${pyflakes_version_output}" + + if ! shellcheck_bin="$(command -v shellcheck)"; then + echo "::error::ShellCheck is missing or not executable" + exit 1 + fi + if [ ! -x "${shellcheck_bin}" ]; then + echo "::error::ShellCheck is missing or not executable" + exit 1 + fi + "${shellcheck_bin}" --version + + { + printf 'ACTIONLINT_BIN=%s\n' "${actionlint_bin}" + printf 'ACTIONLINT_SHELLCHECK_BIN=%s\n' "${shellcheck_bin}" + printf 'ACTIONLINT_PYFLAKES_BIN=%s\n' "${pyflakes_bin}" + } >> "${GITHUB_ENV}" + + - name: Test Actionlint bootstrap contract + run: node --test scripts/ci/actionlint-bootstrap.test.mjs - name: Run actionlint - uses: rhysd/actionlint@v1.7.12 + shell: bash + run: | + set -euo pipefail + checkout_head="$(git rev-parse --verify HEAD)" + workflow_count="$(find .github/workflows -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) -print | awk 'END { print NR }')" + if [ "${workflow_count}" -le 0 ]; then + echo "::error::No workflow files found after checkout" + exit 1 + fi + printf 'Checked out HEAD: %s\nWorkflow files discovered: %s\n' "${checkout_head}" "${workflow_count}" + "${ACTIONLINT_BIN}" \ + -color \ + -verbose \ + -shellcheck "${ACTIONLINT_SHELLCHECK_BIN}" \ + -pyflakes "${ACTIONLINT_PYFLAKES_BIN}" dependency-review: name: Dependency Review diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index 5f24eebd8..7c513780a 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -17,6 +17,10 @@ Companion Active Docs: - **Post-`#1373` diagnostic successor (`#1512`):** structured, bounded correlation plus outer/last-inspected exception type, explicit truncation, aggregate-branch, and SQLite-code evidence is now available when the concurrent-card assertion fails. Normal middleware logs are metadata-only, the client still receives the generic GP-03 500, and no request/user/credential/exception-message content enters the diagnostic sink. Pre/post exact stress, the five-case historical/current concurrency matrix, and five CI-equivalent API runs stayed green and did not reproduce the original 500; therefore no causal exception, `SQLITE_BUSY` classification, retry, quarantine, or product fix is claimed. - **Still open:** retain `#1512` until a real recurrence identifies the cause and the narrow repair passes the full serialized backend suite plus exact-head Windows/Ubuntu CI. +## Staged runway repair (2026-07-27, maintainer-held) + +- **Workflow Lint bootstrap (`#1510`):** replace the Docker Actionlint action, whose pre-checkout Docker Hub manifest fetch timed out in two exact-code CI Extended runs, with a fail-closed direct bootstrap. Pin and checksum Actionlint 1.7.12 plus the Pyflakes 3.4.0 wheel, retain runner ShellCheck through an explicit path, install Pyflakes offline, bound downloads and the job, and log the exact checkout/tool/workflow inventory before verbose linting. Seven focused contract checks and two independent design reviews are complete locally. Exact-head-associated hosted Ubuntu proof is green with both checksums, 7/7 contract checks, and zero errors across 32 workflows without a Docker build. The workflow change remains T4-class and unshipped until maintainer merge plus fresh post-merge proof. + ## Delivery update (2026-07-26, agentic governance) - **Failure-ledger projection gate (`#1492`):** Required Docs Governance now pins Python 3.12 and runs the existing JSONL↔Markdown synchronization unittest before the governance checks, so a JSONL-only change with stale generated Markdown fails Required CI without regeneration masking it. Local agentic update workflows use the distinct render-then-test order so hook-appended JSONL can be projected, and the smoke contract pins both sides of that distinction. diff --git a/docs/STATUS.md b/docs/STATUS.md index 9c1d40b46..221b9866a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,6 +12,9 @@ Windows concurrent-card HTTP-500 diagnostics (2026-07-27, `#1512`; diagnostic-on - **A same-symptom failure recurred after `#1373`, but did not reproduce locally.** Required `API Integration (windows-latest)` on PR `#1505` at source head `6ee17a4849427d679722a9d1e034d4622536f4fe` returned HTTP 500 for 2/5 simultaneous card creates; Ubuntu and one identical-head rerun passed. Pre-change local proof was 20/20 exact runs plus one full API run at 2,128 passed / 4 skipped / 0 failed. Post-diagnostic proof includes focused diagnostics 6/6, the final exact race 20/20, a five-case historical/current concurrency matrix 5/5, and five CI-equivalent full API runs: three at 2,129/4/0 before the final formatter regression and two at 2,130/4/0 after it. The review-fix head then passed its 13/13 focused regressions and a 2,132-passed / 4-skipped / 0-failed full API run. No run captured a spontaneous 500, causal exception, or SQLite classification. - **The new seam improves the next failure's evidence without masking it.** Unhandled middleware emits bounded structured outer/last-inspected exception types, an explicit classification-truncated flag, and SQLite primary/extended codes beside the bounded correlation identity while retaining the generic GP-03 500 response. The bounded graph walk inspects aggregate branches without calling the last inspected wrapper a root. Normal middleware logs are metadata-only; the test harness captures only structured fields. A failed race assertion joins request/response correlation, status/error code, and matching middleware classification into a sanitized 2,048-character diagnostic. It records no request/response body, credential, user content, exception message, exception summary, or exception object. The injected SQLite 5/5 contract proves the diagnostic transport only; it does not classify the original failure as `SQLITE_BUSY`. No retry, quarantine, or product fix was added, and `#1512` remains open. +Workflow-lint runway repair staged (2026-07-27, `#1510`): +- **The maintainer-held workflow candidate removes Actionlint's Docker Hub build from CI Extended without weakening its external-linter contract.** It downloads the pinned Actionlint 1.7.12 Linux archive and Pyflakes 3.4.0 wheel over bounded HTTPS, verifies both published SHA-256 digests before use, installs Pyflakes offline, and passes explicit ShellCheck and Pyflakes paths to Actionlint. The job also logs tool versions, the checked-out head, and the discovered workflow count, and runs a seven-check bootstrap contract before the repository lint. Local focused proof, two independent reviews, and exact-head-associated hosted Ubuntu proof are green: both checksums passed, the contract ran 7/7, and verbose Actionlint found zero errors in 32 workflows without a Docker build. This is not shipped reality yet: maintainer merge and fresh post-merge proof remain required, and runner-provided ShellCheck remains version-drift residual risk. + Required Docs Governance hardening (2026-07-26, `#1492`): - **Required CI now enforces failure-ledger projection synchronization.** The reusable Docs Governance job pins Python 3.12 and runs the existing `failure_ledger.jsonl` ↔ `FAILURE_LEDGER.md` synchronization unittest before its governance checks, so a stale checked-in projection fails without any renderer masking it. Local agentic update workflows intentionally render first and then test so a valid hook-appended JSONL entry can be projected; the smoke contract keeps that distinction from drifting. diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index ce56d8b59..2845ce177 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -56,6 +56,29 @@ Pop-Location if ($code -ne 0) { exit $code } ``` +## Workflow Lint Bootstrap Checks + +CI Extended's `Workflow Lint` job bootstraps checksum-pinned Actionlint and Pyflakes artifacts directly, uses the Ubuntu runner's ShellCheck through an explicit path, runs the focused contract suite, then lints every checked-out workflow verbosely. The hosted job is the authoritative integration proof: its unchanged-head log must show both checksum checks, Actionlint 1.7.12, Pyflakes 3.4.0, the runner ShellCheck version, the checked-out SHA, a positive workflow count, seven passing contract checks, and a zero-error repository lint. A run that fails before checkout or never reads the workflows is not green evidence. + +On native Windows, set Git Bash explicitly because bare `bash` can resolve to the Microsoft Store/WSL alias. The static, ordering, and checksum boundary is the portable fast path: + +```powershell +$env:BASH_BIN = 'C:\Program Files\Git\bin\bash.exe' +node --test --test-name-pattern='pins|bootstrap boundary|checksum verifier' scripts/ci/actionlint-bootstrap.test.mjs +``` + +The full seven-check suite additionally requires local Actionlint, ShellCheck, and Pyflakes executables: + +```powershell +$env:BASH_BIN = 'C:\Program Files\Git\bin\bash.exe' +$env:ACTIONLINT_BIN = '' +$env:ACTIONLINT_SHELLCHECK_BIN = '' +$env:ACTIONLINT_PYFLAKES_BIN = '' +node --test scripts/ci/actionlint-bootstrap.test.mjs +``` + +Do not infer external-linter coverage from Actionlint alone: Actionlint can skip ShellCheck or Pyflakes when they are unavailable. Keep the explicit tool paths and the fixture assertions for `SC2086` and the Pyflakes undefined-name diagnostic. + ## Agentic Operating Layer Smoke Checks For docs/skill/hook-only agentic changes, use targeted checks rather than the full product suite unless product runtime files changed. These local update-and-verify sequences render the failure ledger before testing synchronization so a valid hook-appended JSONL entry can become visible. Required CI deliberately does not render and keeps its test-before-governance order, so an unprojected JSONL change fails instead of being masked. On Windows PowerShell, use the verified Python launcher and compile hook sources in memory so verification does not leave `__pycache__` output: diff --git a/docs/agentic/FAILURE_LEDGER.md b/docs/agentic/FAILURE_LEDGER.md index 7eb72ce44..3f1592363 100644 --- a/docs/agentic/FAILURE_LEDGER.md +++ b/docs/agentic/FAILURE_LEDGER.md @@ -25,6 +25,7 @@ Rows sharing a surface and first tracking issue in `future_fix` show only their | 2026-07-26 | non_blocking_risk | agent/tool-command-composition | Resolution record for the repeated #1490 PowerShell/native command-composition failures; the original recurrence remains in append-only history | No workaround remains; use the copyable safe forms and classify future failures through the ledger process | #1490 resolved by PR #1491 merge 61f718af941c56c31b8b7595333b9debd6e47da8: the MCP tooling guide now covers collected foreach output, optional rg exits, safe mu... | resolved | | 2026-07-26 | blocker | agent/python-launcher | Resolution staged for #1487: exact Windows handlers and agent-utility permissions use py -3 -B, POSIX permissions retain python3 -B, and smoke children use the ... | Until the T4-class hook configuration is maintainer-merged, continue invoking py -3 -B explicitly on native Windows | #1487 resolution is staged on the human-held PR with sequential CPython 3.14 and 3.13 smoke coverage; append the final resolved record after merge and issue clo... | open | | 2026-07-26 | blocker | agent/powershell-deny-floor | The repo PreToolUse deny policy is matched only to Bash, so native PowerShell tool commands do not traverse the repository's destructive-command policy | Treat the current configured-handler smoke as Bash-payload-only proof; rely on existing tool permissions and do not claim native-PowerShell interception | #1497: add reviewed T4 native-PowerShell deny-policy coverage with direct allow and deny canaries before claiming interception | open | +| 2026-07-27 | blocker | ci/extended-workflow | CI Extended runs 30236731281 (job 89885846264) and 30237518666 (job 89888612738) both failed before checkout because the rhysd/actionlint@v1.7.12 Docker action ... | Treat both runs as missing Workflow Lint evidence, do not retry the same Docker bootstrap, and do not merge affected PRs until a direct pinned bootstrap passes ... | #1510: replace the Docker action with checksum-pinned Actionlint and Pyflakes downloads, explicit ShellCheck/Pyflakes paths, focused contract tests, and exact-h... | open | | 2026-07-27 | pre_existing_noise | test/sqlite-concurrency | Correction to the #1373 resolution record: Required API Integration (windows-latest) on PR #1505 source head 6ee17a4849427d679722a9d1e034d4622536f4fe returned H... | Keep every recurrence red and use the bounded #1512 correlation/exception-classification diagnostics to capture the first server-side cause; do not retry the re... | #1373 resolved the proven #1282 SQLite registration-parity cause; successor #1512 remains open after its diagnostic-only slice until a recurrence identifies the... | open | | 2026-07-27 | blocker | test/testcontainers-dockerless-skip | Taskdeck.Integration.Tests constructed its PostgreSQL Testcontainer in the collection-fixture constructor, so Testcontainers validated an unavailable Docker end... | Run the focused non-container projects while #1518 is in review; do not retry a known-hanging Docker probe merely to obtain a local full-suite verdict | #1518 defers PostgreSqlBuilder.Build until after DockerAvailableCheck succeeds, proves the unavailable path never invokes the factory, and retains Docker-backed... | resolved | diff --git a/docs/agentic/failure_ledger.jsonl b/docs/agentic/failure_ledger.jsonl index f90410b47..4d5bfb80e 100644 --- a/docs/agentic/failure_ledger.jsonl +++ b/docs/agentic/failure_ledger.jsonl @@ -25,5 +25,6 @@ {"ts":"2026-07-25T23:48:15Z","class":"blocker","surface":"agent/python-launcher","failure":"Native-Windows python and python3 commands resolve to unusable Microsoft Store aliases, so configured agent hooks and documented failure-ledger utilities do not run even though CPython is installed behind py -3","workaround":"Invoke py -3 -B explicitly on Windows and python3 -B on POSIX; do not install another interpreter or mutate global PATH","future_fix":"#1487: make platform launchers explicit across configured handlers, permissions, smoke execution, and mirrored guidance","status":"open"} {"ts":"2026-07-26T10:56:00Z","class":"blocker","surface":"agent/python-launcher","failure":"Resolution staged for #1487: exact Windows handlers and agent-utility permissions use py -3 -B, POSIX permissions retain python3 -B, and smoke children use the active sys.executable -B","workaround":"Until the T4-class hook configuration is maintainer-merged, continue invoking py -3 -B explicitly on native Windows","future_fix":"#1487 resolution is staged on the human-held PR with sequential CPython 3.14 and 3.13 smoke coverage; append the final resolved record after merge and issue closure","status":"open"} {"ts":"2026-07-26T11:36:55Z","class":"blocker","surface":"agent/powershell-deny-floor","failure":"The repo PreToolUse deny policy is matched only to Bash, so native PowerShell tool commands do not traverse the repository's destructive-command policy","workaround":"Treat the current configured-handler smoke as Bash-payload-only proof; rely on existing tool permissions and do not claim native-PowerShell interception","future_fix":"#1497: add reviewed T4 native-PowerShell deny-policy coverage with direct allow and deny canaries before claiming interception","status":"open"} +{"ts":"2026-07-27T05:20:54Z","class":"blocker","surface":"ci/extended-workflow","failure":"CI Extended runs 30236731281 (job 89885846264) and 30237518666 (job 89888612738) both failed before checkout because the rhysd/actionlint@v1.7.12 Docker action could not fetch Docker Hub manifests after repeated timeouts; the workflow code was identical and every other substantive job passed","workaround":"Treat both runs as missing Workflow Lint evidence, do not retry the same Docker bootstrap, and do not merge affected PRs until a direct pinned bootstrap passes on the exact hosted Ubuntu head","future_fix":"#1510: replace the Docker action with checksum-pinned Actionlint and Pyflakes downloads, explicit ShellCheck/Pyflakes paths, focused contract tests, and exact-head hosted proof; append a resolved record only after maintainer merge and a fresh green run","status":"open"} {"ts":"2026-07-27T06:50:27Z","class":"pre_existing_noise","surface":"test/sqlite-concurrency","failure":"Correction to the #1373 resolution record: Required API Integration (windows-latest) on PR #1505 source head 6ee17a4849427d679722a9d1e034d4622536f4fe returned HTTP 500 for 2 of 5 concurrent card-create requests while Ubuntu passed; an identical-head rerun and local stress passed, so the causal exception and any SQLite/SQLITE_BUSY classification remain unproven","workaround":"Keep every recurrence red and use the bounded #1512 correlation/exception-classification diagnostics to capture the first server-side cause; do not retry the request, quarantine the test, or accept HTTP 500 as success","future_fix":"#1373 resolved the proven #1282 SQLite registration-parity cause; successor #1512 remains open after its diagnostic-only slice until a recurrence identifies the root cause and a narrow corrective fix is proven by the full backend and exact-head Windows/Ubuntu gates","status":"open"} {"ts":"2026-07-27T09:20:44Z","class":"blocker","surface":"test/testcontainers-dockerless-skip","failure":"Taskdeck.Integration.Tests constructed its PostgreSQL Testcontainer in the collection-fixture constructor, so Testcontainers validated an unavailable Docker endpoint before DockerAvailableCheck could mark the 28 container tests skipped; this blocked the serialized backend gate on Dockerless developer hosts","workaround":"Run the focused non-container projects while #1518 is in review; do not retry a known-hanging Docker probe merely to obtain a local full-suite verdict","future_fix":"#1518 defers PostgreSqlBuilder.Build until after DockerAvailableCheck succeeds, proves the unavailable path never invokes the factory, and retains Docker-backed CI as the positive-lifecycle gate","status":"resolved"} diff --git a/scripts/ci/actionlint-bootstrap.test.mjs b/scripts/ci/actionlint-bootstrap.test.mjs new file mode 100644 index 000000000..d852cb217 --- /dev/null +++ b/scripts/ci/actionlint-bootstrap.test.mjs @@ -0,0 +1,203 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const workflowPath = fileURLToPath(new URL('../../.github/workflows/ci-extended.yml', import.meta.url)) +const malformedFixturePath = fileURLToPath(new URL('./fixtures/actionlint-malformed.yml', import.meta.url)) +const externalLintersFixturePath = fileURLToPath(new URL('./fixtures/actionlint-external-linters.yml', import.meta.url)) +const repoRoot = fileURLToPath(new URL('../../', import.meta.url)) + +const expectedVersion = '1.7.12' +const expectedArchive = 'actionlint_1.7.12_linux_amd64.tar.gz' +const expectedChecksum = '8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8' +const expectedUrl = `https://github.com/rhysd/actionlint/releases/download/v${expectedVersion}/${expectedArchive}` +const expectedPyflakesVersion = '3.4.0' +const expectedPyflakesWheel = 'pyflakes-3.4.0-py2.py3-none-any.whl' +const expectedPyflakesChecksum = 'f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f' +const expectedPyflakesUrl = `https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/${expectedPyflakesWheel}` + +async function loadWorkflow() { + return readFile(workflowPath, 'utf8') +} + +function requiredToolPath(variableName) { + const toolPath = process.env[variableName] + assert.ok(toolPath, `${variableName} must point to the installed tool`) + return toolPath +} + +function runActionlint(fixturePath) { + return spawnSync( + requiredToolPath('ACTIONLINT_BIN'), + [ + '-shellcheck', requiredToolPath('ACTIONLINT_SHELLCHECK_BIN'), + '-pyflakes', requiredToolPath('ACTIONLINT_PYFLAKES_BIN'), + fixturePath, + ], + { encoding: 'utf8' }, + ) +} + +function runChecksumVerifier(expectedChecksum) { + const bash = process.platform === 'win32' + ? requiredToolPath('BASH_BIN') + : process.env.BASH_BIN || 'bash' + + return spawnSync( + bash, + [ + 'scripts/ci/verify-sha256.sh', + expectedChecksum, + 'scripts/ci/fixtures/actionlint-malformed.yml', + ], + { cwd: repoRoot, encoding: 'utf8' }, + ) +} + +test('pins the Actionlint Linux release contract without the Docker action', async () => { + const workflow = await loadWorkflow() + const versionMatch = workflow.match(/^\s+ACTIONLINT_VERSION:\s+"([^"]+)"$/m) + const archiveMatch = workflow.match(/^\s+archive_name="([^"]+)"$/m) + const urlMatch = workflow.match(/^\s+download_url="([^"]+)"$/m) + + assert.ok(versionMatch, 'Missing pinned Actionlint version') + assert.ok(archiveMatch, 'Missing pinned Actionlint archive template') + assert.ok(urlMatch, 'Missing pinned Actionlint download URL template') + assert.ok(workflow.includes(`ACTIONLINT_ARCHIVE_SHA256: ${expectedChecksum}`)) + + const version = versionMatch[1] + const archive = archiveMatch[1].replace('${ACTIONLINT_VERSION}', version) + const downloadUrl = urlMatch[1] + .replace('${ACTIONLINT_VERSION}', version) + .replace('${archive_name}', archive) + + assert.equal(version, expectedVersion) + assert.equal(archive, expectedArchive) + assert.equal(downloadUrl, expectedUrl) + assert.doesNotMatch(workflow, /^\s*uses:\s*rhysd\/actionlint@/m) +}) + +test('pins the Pyflakes wheel and installs it without an index lookup', async () => { + const workflow = await loadWorkflow() + const versionMatch = workflow.match(/^\s+PYFLAKES_VERSION:\s+"([^"]+)"$/m) + const wheelMatch = workflow.match(/^\s+pyflakes_wheel_name="([^"]+)"$/m) + const urlMatch = workflow.match(/^\s+pyflakes_download_url="([^"]+)"$/m) + + assert.ok(versionMatch, 'Missing pinned Pyflakes version') + assert.ok(wheelMatch, 'Missing pinned Pyflakes wheel template') + assert.ok(urlMatch, 'Missing pinned Pyflakes URL template') + assert.ok(workflow.includes(`PYFLAKES_WHEEL_SHA256: ${expectedPyflakesChecksum}`)) + + const version = versionMatch[1] + const wheel = wheelMatch[1].replace('${PYFLAKES_VERSION}', version) + const downloadUrl = urlMatch[1].replace('${pyflakes_wheel_name}', wheel) + + assert.equal(version, expectedPyflakesVersion) + assert.equal(wheel, expectedPyflakesWheel) + assert.equal(downloadUrl, expectedPyflakesUrl) + assert.match(workflow, /-m pip install[\s\S]*?--no-deps \\\r?\n\s+--no-index/) + assert.ok(workflow.includes('bash scripts/ci/verify-sha256.sh "${PYFLAKES_WHEEL_SHA256}" "${pyflakes_wheel_path}"')) +}) + +test('keeps checkout and every bootstrap boundary fail closed before linting', async () => { + const workflow = await loadWorkflow() + const bootstrapMatch = workflow.match( + /- name: Install Actionlint toolchain[\s\S]*?(?=\r?\n\s+- name: Test Actionlint bootstrap contract)/, + ) + assert.ok(bootstrapMatch, 'Missing Actionlint bootstrap step') + const bootstrap = bootstrapMatch[0] + const orderedMarkers = [ + '- name: Checkout', + '- name: Install Actionlint toolchain', + '--fail', + 'bash scripts/ci/verify-sha256.sh "${ACTIONLINT_ARCHIVE_SHA256}" "${archive_path}"', + 'tar --extract --gzip', + 'version_output=', + 'bash scripts/ci/verify-sha256.sh "${PYFLAKES_WHEEL_SHA256}" "${pyflakes_wheel_path}"', + 'python3 -m venv', + 'pyflakes_version_output=', + '- name: Test Actionlint bootstrap contract', + '- name: Run actionlint', + 'git rev-parse --verify HEAD', + '"${ACTIONLINT_BIN}"', + ] + + let previousIndex = -1 + for (const marker of orderedMarkers) { + const markerIndex = workflow.indexOf(marker) + assert.ok(markerIndex > previousIndex, `Expected workflow marker in order: ${marker}`) + previousIndex = markerIndex + } + + assert.match(workflow, /- name: Install Actionlint toolchain[\s\S]*?set -euo pipefail/) + assert.match(workflow, /workflow-lint:[\s\S]*?timeout-minutes: 10/) + assert.match(workflow, /- name: Checkout\r?\n\s+uses: actions\/checkout@v7\r?\n\s+with:\r?\n\s+persist-credentials: false/) + assert.match(workflow, /--output "\$\{archive_path\}" \\\r?\n\s+"\$\{download_url\}"/) + assert.match(workflow, /--output "\$\{pyflakes_wheel_path\}" \\\r?\n\s+"\$\{pyflakes_download_url\}"/) + assert.ok(workflow.includes('bash scripts/ci/verify-sha256.sh "${ACTIONLINT_ARCHIVE_SHA256}" "${archive_path}"')) + assert.ok(workflow.includes('bash scripts/ci/verify-sha256.sh "${PYFLAKES_WHEEL_SHA256}" "${pyflakes_wheel_path}"')) + assert.ok(workflow.includes('tar --extract --gzip --file "${archive_path}" --directory "${install_dir}" actionlint')) + assert.ok(workflow.includes('if [ ! -f "${actionlint_bin}" ] || [ -L "${actionlint_bin}" ]; then')) + assert.ok(workflow.includes('version_output="$("${actionlint_bin}" -version)"')) + assert.ok(workflow.includes('if [ "${actual_version}" != "${ACTIONLINT_VERSION}" ]; then')) + assert.ok(workflow.includes('pyflakes_version_output="$("${pyflakes_bin}" --version)"')) + + for (const boundedCurlFlag of [ + '--connect-timeout 15', + '--max-time 120', + '--retry 3', + '--retry-all-errors', + '--retry-max-time 240', + ]) { + assert.equal(bootstrap.split(boundedCurlFlag).length - 1, 2, `Expected both downloads to use ${boundedCurlFlag}`) + } + + assert.ok(workflow.includes("checkout_head=\"$(git rev-parse --verify HEAD)\"")) + assert.ok(workflow.includes('workflow_count="$(find .github/workflows')) + assert.ok(workflow.includes("printf 'Checked out HEAD: %s\\nWorkflow files discovered: %s\\n'")) + assert.match( + workflow, + /"\$\{ACTIONLINT_BIN\}" \\\r?\n\s+-color \\\r?\n\s+-verbose \\\r?\n\s+-shellcheck "\$\{ACTIONLINT_SHELLCHECK_BIN\}" \\\r?\n\s+-pyflakes "\$\{ACTIONLINT_PYFLAKES_BIN\}"/, + ) +}) + +test('checksum verifier accepts the expected digest', async () => { + const fixture = await readFile(malformedFixturePath) + const expectedChecksum = createHash('sha256').update(fixture).digest('hex') + const result = runChecksumVerifier(expectedChecksum) + + assert.ifError(result.error) + assert.equal(result.status, 0, result.stderr) + assert.match(result.stdout, /actionlint-malformed\.yml: OK/) +}) + +test('checksum verifier rejects a corrupt digest', () => { + const result = runChecksumVerifier('0'.repeat(64)) + + assert.ifError(result.error) + assert.equal(result.status, 1, 'Corrupt checksum unexpectedly passed verification') + assert.match(`${result.stdout}\n${result.stderr}`, /actionlint-malformed\.yml: FAILED/) +}) + +test('rejects a malformed workflow fixture with the installed Actionlint binary', () => { + const result = runActionlint(malformedFixturePath) + + assert.ifError(result.error) + assert.equal(result.status, 1, 'Malformed workflow did not produce Actionlint exit 1') + const output = `${result.stdout}\n${result.stderr}` + assert.match(output, /actionlint-malformed\.yml/) + assert.match(output, /"runs-on" section is missing in job "malformed"/) +}) + +test('runs ShellCheck and Pyflakes through explicit Actionlint paths', () => { + const result = runActionlint(externalLintersFixturePath) + + assert.ifError(result.error) + assert.equal(result.status, 1, 'External linter fixture did not produce Actionlint exit 1') + const output = `${result.stdout}\n${result.stderr}` + assert.match(output, /SC2086/) + assert.match(output, /undefined name 'undefined_name'.*\[pyflakes\]/) +}) diff --git a/scripts/ci/fixtures/actionlint-external-linters.yml b/scripts/ci/fixtures/actionlint-external-linters.yml new file mode 100644 index 000000000..8c1adb88f --- /dev/null +++ b/scripts/ci/fixtures/actionlint-external-linters.yml @@ -0,0 +1,19 @@ +name: Actionlint external linter fixture + +on: + workflow_dispatch: + +jobs: + external-linters: + runs-on: ubuntu-latest + steps: + - name: Trigger ShellCheck + shell: bash + run: | + value="two words" + echo $value + + - name: Trigger Pyflakes + shell: python + run: | + print(undefined_name) diff --git a/scripts/ci/fixtures/actionlint-malformed.yml b/scripts/ci/fixtures/actionlint-malformed.yml new file mode 100644 index 000000000..70af27863 --- /dev/null +++ b/scripts/ci/fixtures/actionlint-malformed.yml @@ -0,0 +1,9 @@ +name: Actionlint malformed fixture + +on: + workflow_dispatch: + +jobs: + malformed: + steps: + - run: echo "This job deliberately has no runs-on declaration" diff --git a/scripts/ci/verify-sha256.sh b/scripts/ci/verify-sha256.sh new file mode 100644 index 000000000..90c2588d6 --- /dev/null +++ b/scripts/ci/verify-sha256.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: verify-sha256.sh " >&2 + exit 2 +fi + +expected_sha256="$1" +artifact_path="$2" + +if [[ ! "${expected_sha256}" =~ ^[0-9a-fA-F]{64}$ ]]; then + echo "expected SHA-256 must contain exactly 64 hexadecimal characters" >&2 + exit 2 +fi + +if [ ! -f "${artifact_path}" ] || [ -L "${artifact_path}" ]; then + echo "artifact must be a regular file and not a symbolic link: ${artifact_path}" >&2 + exit 2 +fi + +printf '%s %s\n' "${expected_sha256,,}" "${artifact_path}" \ + | sha256sum --check --strict -