Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 187 additions & 27 deletions .github/workflows/deploy-deployer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -483,14 +483,13 @@ jobs:
if-no-files-found: error
retention-days: 1

build:
name: Build without deployment secrets
audit:
name: Audit locked build inputs
needs: dependencies
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout_minutes }}
outputs:
artifact_id: ${{ steps.artifact.outputs.artifact-id }}
release_sha256: ${{ steps.release-package.outputs.sha256 }}
env:
COMPOSER_NO_DEV: 1
COMPOSER_CACHE_DIR: ${{ github.workspace }}/.sympress-composer-cache
Expand Down Expand Up @@ -554,26 +553,6 @@ jobs:
corepack enable
fi

- name: Build Node assets
if: ${{ steps.node.outputs.enabled == 'true' }}
working-directory: ${{ inputs.working_directory }}
shell: bash
env:
BUILD_SCRIPT: ${{ inputs.build_script }}
COMPOSER_DISABLE_NETWORK: '1'
run: |
set -euo pipefail
unset COMPOSER_AUTH NODE_AUTH_TOKEN NPM_TOKEN SSH_AUTH_SOCK SSH_AGENT_PID GIT_SSH_COMMAND NPM_CONFIG_USERCONFIG
if node -e "const p=require('./package.json'); process.exit(p.scripts && p.scripts[process.env.BUILD_SCRIPT] ? 0 : 1)"; then
if [ -f pnpm-lock.yaml ]; then
pnpm run "$BUILD_SCRIPT"
elif [ -f yarn.lock ]; then
yarn "$BUILD_SCRIPT"
else
npm run "$BUILD_SCRIPT"
fi
fi

- name: Audit production Composer dependencies
working-directory: ${{ inputs.working_directory }}
run: composer --no-plugins audit --locked --no-dev --no-interaction
Expand Down Expand Up @@ -619,7 +598,7 @@ jobs:
run: |
set -euo pipefail
python3 - <<'PY'
import json, os, pathlib, urllib.parse, uuid
import hashlib, json, os, pathlib, urllib.parse, uuid
root = pathlib.Path.cwd().resolve()
working = (root / os.environ['WD']).resolve()
if not working.is_relative_to(root):
Expand All @@ -639,6 +618,10 @@ jobs:
if not isinstance(packages, list):
raise RuntimeError('Invalid Composer inventory')
lock = json.loads((working / 'composer.lock').read_text())
expected = {p['name']: p['version'] for p in lock.get('packages', [])}
actual = {p['name']: p['version'] for p in packages}
if actual != expected:
raise RuntimeError('Installed production inventory differs from the audited Composer lock')
dev = {p['name'] for p in lock.get('packages-dev', [])}
for package in packages:
if package.get('name') in dev:
Expand Down Expand Up @@ -667,8 +650,130 @@ jobs:
if target.is_symlink() or (target.exists() and target.stat().st_nlink != 1):
raise RuntimeError('SBOM output must not alias existing files')
target.write_text(json.dumps(sbom, sort_keys=True, indent=2) + '\n')
locks = {}
for name in ['composer.lock', 'package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock']:
path = working / name
if path.is_file():
locks[str(path.relative_to(root))] = hashlib.sha256(path.read_bytes()).hexdigest()
manifest = root / 'sympress-audit-locks.json'
if manifest.is_symlink() or (manifest.exists() and manifest.stat().st_nlink != 1):
raise RuntimeError('Audit manifest must not alias existing files')
manifest.write_text(json.dumps({'commit': os.environ['GITHUB_SHA'], 'locks': locks, 'packages': expected}, sort_keys=True))
PY


- name: Upload audited inventory
id: artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: sympress-deploy-audit-${{ github.run_id }}-${{ github.run_attempt }}
path: |
sympress-sbom.cdx.json
sympress-audit-locks.json
if-no-files-found: error
retention-days: 1

build:
name: Build without deployment secrets
needs: [dependencies, audit]
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout_minutes }}
outputs:
artifact_id: ${{ steps.artifact.outputs.artifact-id }}
release_sha256: ${{ steps.release-package.outputs.sha256 }}
env:
COMPOSER_NO_DEV: 1
COMPOSER_CACHE_DIR: ${{ github.workspace }}/.sympress-composer-cache
steps:
- name: Download dependencies artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ needs.dependencies.outputs.artifact_id }}
path: ${{ runner.temp }}/sympress-dependencies

- name: Restore dependencies artifact
shell: bash
env:
ARTIFACT_KIND: dependencies
run: |
set -euo pipefail
python3 - <<'PY'
import json, os, pathlib, tarfile
root = pathlib.Path.cwd()
kind = os.environ['ARTIFACT_KIND']
source = pathlib.Path(os.environ['RUNNER_TEMP']) / ('sympress-' + kind) / (kind + '.tgz')
with tarfile.open(source) as archive:
archive.extractall(root, filter='data')
metadata = json.loads((root / '.sympress-artifact.json').read_text())
if metadata != {'commit': os.environ['GITHUB_SHA'], 'run_id': os.environ['GITHUB_RUN_ID'], 'kind': kind}:
raise RuntimeError('Artifact provenance mismatch')
PY

- name: Set up PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: ${{ inputs.php_version }}
extensions: ${{ inputs.php_extensions }}
tools: composer:v2, ${{ inputs.php_tools }}
coverage: none

- name: Detect package.json
id: node
shell: bash
env:
WD: ${{ inputs.working_directory }}
run: |
test -f "$WD/package.json" && echo "enabled=true" >> "$GITHUB_OUTPUT" || echo "enabled=false" >> "$GITHUB_OUTPUT"

- name: Set up Node
if: ${{ steps.node.outputs.enabled == 'true' }}
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node_version }}

- name: Activate package manager
if: ${{ steps.node.outputs.enabled == 'true' }}
working-directory: ${{ inputs.working_directory }}
shell: bash
run: |
set -euo pipefail
if [ -f pnpm-lock.yaml ] || [ -f yarn.lock ]; then
if ! command -v corepack >/dev/null 2>&1; then
npm install --global corepack@0.34.6 --ignore-scripts --registry=https://registry.npmjs.org/
fi
corepack enable
fi

- name: Build Node assets
if: ${{ steps.node.outputs.enabled == 'true' }}
working-directory: ${{ inputs.working_directory }}
shell: bash
env:
BUILD_SCRIPT: ${{ inputs.build_script }}
COMPOSER_DISABLE_NETWORK: '1'
run: |
set -euo pipefail
unset COMPOSER_AUTH NODE_AUTH_TOKEN NPM_TOKEN SSH_AUTH_SOCK SSH_AGENT_PID GIT_SSH_COMMAND NPM_CONFIG_USERCONFIG
if node -e "const p=require('./package.json'); process.exit(p.scripts && p.scripts[process.env.BUILD_SCRIPT] ? 0 : 1)"; then
if [ -f pnpm-lock.yaml ]; then
pnpm run "$BUILD_SCRIPT"
elif [ -f yarn.lock ]; then
yarn "$BUILD_SCRIPT"
else
npm run "$BUILD_SCRIPT"
fi
fi

- name: Download audited inventory
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ needs.audit.outputs.artifact_id }}
path: ${{ runner.temp }}/sympress-audit

- name: Include audited SBOM
shell: bash
run: cp "$RUNNER_TEMP/sympress-audit/sympress-sbom.cdx.json" sympress-sbom.cdx.json

- name: Package release artifact
id: release-package
shell: bash
Expand Down Expand Up @@ -741,9 +846,64 @@ jobs:
if-no-files-found: error
retention-days: 1


verify:
name: Verify release against audited inventory
needs: [audit, build]
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout_minutes }}
steps:
- name: Download built release
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ needs.build.outputs.artifact_id }}
path: ${{ runner.temp }}/sympress-release

- name: Download independent audited inventory
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ needs.audit.outputs.artifact_id }}
path: ${{ runner.temp }}/sympress-audit

- name: Verify immutable audit evidence
shell: bash
env:
EXPECTED_SHA256: ${{ needs.build.outputs.release_sha256 }}
WD: ${{ inputs.working_directory }}
run: |
set -euo pipefail
python3 - <<'PY'
import hashlib, json, os, pathlib, tarfile
runner = pathlib.Path(os.environ['RUNNER_TEMP'])
archive_path = runner / 'sympress-release/release.tgz'
if hashlib.sha256(archive_path.read_bytes()).hexdigest() != os.environ['EXPECTED_SHA256']:
raise RuntimeError('Release digest mismatch')
evidence = json.loads((runner / 'sympress-audit/sympress-audit-locks.json').read_text())
if evidence['commit'] != os.environ['GITHUB_SHA']:
raise RuntimeError('Audited commit mismatch')
working = pathlib.PurePosixPath(os.environ['WD'])
if working.is_absolute() or '..' in working.parts:
raise RuntimeError('Invalid audited working directory')
with tarfile.open(archive_path) as archive:
def read(name):
member = archive.getmember('./' + name)
if not member.isfile() or member.size > 10485760:
raise RuntimeError('Missing regular, bounded inventory file: ' + name)
return archive.extractfile(member).read()
for name, digest in evidence['locks'].items():
if hashlib.sha256(read(name)).hexdigest() != digest:
raise RuntimeError('Build changed an audited lockfile: ' + name)
installed = json.loads(read(str(working / 'vendor/composer/installed.json')))
packages = installed.get('packages') if isinstance(installed, dict) else installed
if not isinstance(packages, list) or {p['name']: p['version'] for p in packages} != evidence['packages']:
raise RuntimeError('Build changed the audited production inventory')
if read('sympress-sbom.cdx.json') != (runner / 'sympress-audit/sympress-sbom.cdx.json').read_bytes():
raise RuntimeError('Build changed the independent SBOM')
PY

attest:
name: Attest the deployment archive
needs: build
needs: [build, verify]
if: ${{ inputs.artifact_attestation }}
runs-on: ubuntu-latest
permissions:
Expand Down Expand Up @@ -796,8 +956,8 @@ jobs:

deploy:
name: Deploy
needs: [build, attest]
if: ${{ always() && needs.build.result == 'success' && (!inputs.artifact_attestation || needs.attest.result == 'success') }}
needs: [build, verify, attest]
if: ${{ always() && needs.build.result == 'success' && needs.verify.result == 'success' && (!inputs.artifact_attestation || needs.attest.result == 'success') }}
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down
14 changes: 12 additions & 2 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,12 @@ Outputs exposed to caller workflows:
| `deploy_warnings` | Retained compatibility output; empty. |
| `deploy_log_excerpt` | Retained compatibility output; empty. |

The workflow uses isolated fetch, build and deploy jobs. `dependencies` installs the project and
The workflow uses isolated fetch, audit, build, verification and deploy jobs. `dependencies` installs the project and
Deployer locks with scripts and plugins disabled and fetch-only credentials.
`build` restores that run's artifact and runs application build scripts without
`audit` restores that immutable dependency artifact without running build code,
audits Composer and Node dependencies, and records lock hashes, the installed
production inventory and CycloneDX SBOM in a separate artifact.
`build` restores the dependency artifact and runs application build scripts without
any operator secrets or deployment environment. `deploy` checks out the exact
tested caller commit into a separate trusted source directory and installs its
deployment lock with scripts/plugins disabled and fetch-only authentication.
Expand All @@ -76,6 +79,13 @@ VCS metadata and private caches are excluded. Internal relative symlinks retain
pnpm's dependency resolution; absolute or external links and links or hardlinks
to private files fail packaging. Restores use Python's `data` extraction filter.

Before signing or deployment, `verify` runs on a fresh runner and compares release
metadata with the independently uploaded audit artifact. A changed lockfile,
missing or different Composer package inventory, or altered SBOM stops the run.
Build-provided executables and PATH changes are never used for this verification.
The SBOM inventories audited dependency inputs; generated application code is
outside that statement. Non-npm JavaScript inventories retain an explicit limitation.

Deployment recipes run from the trusted checkout. Use the absolute
`SYMPRESS_RELEASE_DIRECTORY` environment variable as the upload source, for example
`upload(getenv('SYMPRESS_RELEASE_DIRECTORY') . '/', '{{release_path}}')`.
Expand Down
9 changes: 9 additions & 0 deletions docs/release-1.2.5.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Workflows 1.2.5

- Audit dependencies and generate the production CycloneDX SBOM in a separate job before application build code runs.
- Seal the audited lockfiles and Composer inventory in an independently uploaded artifact.
- Verify the completed release in a fresh job against that artifact before signing or deployment. Modified locks, missing packages and a replaced SBOM fail verification.
- Keep fetch credentials, trusted deployment tools and application build execution isolated; callable inputs and outputs remain compatible.
- Update the documentation toolchain to the patched KaTeX 0.18.2 dependency.

The SBOM describes audited dependency inputs. It does not certify the behavior of generated application code. Non-npm JavaScript inventories continue to be marked incomplete.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"test:consumers": "node scripts/validate-consumers.mjs",
"test:contracts": "node scripts/validate-contracts.mjs",
"test:shell": "node scripts/test-runner-shell.mjs",
"test:artifacts": "node scripts/test-deploy-artifact.mjs && node scripts/test-release-integrity.mjs",
"test:artifacts": "node scripts/test-deploy-artifact.mjs && node scripts/test-release-integrity.mjs && node scripts/test-audit-isolation.mjs",
"test:canary": "node scripts/test-canary-notification.mjs",
"test:release": "node scripts/test-braces-depth.mjs && node scripts/test-release-tooling.mjs"
},
Expand All @@ -27,6 +27,7 @@
"braces": "$braces",
"js-yaml": "4.3.2",
"markdown-it": "14.3.2",
"smol-toml": "1.9.0"
"smol-toml": "1.9.0",
"katex": "0.18.2"
}
}
Loading
Loading