From 77cc1d1cc06e1abba3d214ff9d9898188c498469 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 27 Jul 2026 06:16:59 +0100 Subject: [PATCH 1/3] Harden Actionlint workflow bootstrap Signed-off-by: Chris0Jeky --- .github/workflows/ci-extended.yml | 126 ++++++++++- scripts/ci/actionlint-bootstrap.test.mjs | 203 ++++++++++++++++++ .../fixtures/actionlint-external-linters.yml | 19 ++ scripts/ci/fixtures/actionlint-malformed.yml | 9 + scripts/ci/verify-sha256.sh | 23 ++ 5 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 scripts/ci/actionlint-bootstrap.test.mjs create mode 100644 scripts/ci/fixtures/actionlint-external-linters.yml create mode 100644 scripts/ci/fixtures/actionlint-malformed.yml create mode 100644 scripts/ci/verify-sha256.sh 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/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 - From b44d938ff3222069d3bf0fb33821f1583fbdcf64 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 27 Jul 2026 06:21:39 +0100 Subject: [PATCH 2/3] Document Workflow Lint bootstrap gate Signed-off-by: Chris0Jeky --- docs/IMPLEMENTATION_MASTERPLAN.md | 4 ++++ docs/STATUS.md | 3 +++ docs/TESTING_GUIDE.md | 25 ++++++++++++++++++++++++- docs/agentic/FAILURE_LEDGER.md | 1 + docs/agentic/failure_ledger.jsonl | 1 + 5 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index b37d752aa..2dfad1e25 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -10,6 +10,10 @@ Companion Active Docs: - `docs/MANUAL_TEST_CHECKLIST.md` - `docs/GOLDEN_PRINCIPLES.md` +## 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 hosted Ubuntu proof is still required; the workflow change is T4-class and must remain unmerged until the maintainer accepts it. + ## 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 59b269bbb..02d442f52 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,6 +2,9 @@ Last Updated: 2026-07-27 +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 and two independent reviews are complete. This is not shipped reality yet: exact-head hosted Ubuntu proof and maintainer merge 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 f1e111d22..76d74e17f 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -2,7 +2,7 @@ This is the active testing guide for Taskdeck. -Last Updated: 2026-07-26 +Last Updated: 2026-07-27 Companion Active Docs: - `docs/STATUS.md` - `docs/IMPLEMENTATION_MASTERPLAN.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 5ec2c0c5c..c94b74b5f 100644 --- a/docs/agentic/FAILURE_LEDGER.md +++ b/docs/agentic/FAILURE_LEDGER.md @@ -26,6 +26,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 | ## Classification diff --git a/docs/agentic/failure_ledger.jsonl b/docs/agentic/failure_ledger.jsonl index aed23731f..0bb89b0e3 100644 --- a/docs/agentic/failure_ledger.jsonl +++ b/docs/agentic/failure_ledger.jsonl @@ -25,3 +25,4 @@ {"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"} From 3dac09dab46f67e9d42e6473335b352c66562c10 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 27 Jul 2026 06:34:31 +0100 Subject: [PATCH 3/3] Record Workflow Lint hosted proof Signed-off-by: Chris0Jeky --- docs/IMPLEMENTATION_MASTERPLAN.md | 2 +- docs/STATUS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index 2dfad1e25..fc89a4cca 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -12,7 +12,7 @@ Companion Active Docs: ## 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 hosted Ubuntu proof is still required; the workflow change is T4-class and must remain unmerged until the maintainer accepts it. +- **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) diff --git a/docs/STATUS.md b/docs/STATUS.md index 02d442f52..601675bef 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -3,7 +3,7 @@ Last Updated: 2026-07-27 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 and two independent reviews are complete. This is not shipped reality yet: exact-head hosted Ubuntu proof and maintainer merge remain required, and runner-provided ShellCheck remains version-drift residual risk. +- **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.